Compare commits
156 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60d48ae532 | |||
| a796577199 | |||
| 1814cba458 | |||
| 4c5db24963 | |||
| 2d44c920fd | |||
| 7bc60a4e10 | |||
| 51d0314c1c | |||
| 8fc71d677c | |||
| 8a821bc91d | |||
| 33fa5986c8 | |||
| f629f6a50c | |||
| c79ac23a15 | |||
| 5aab716245 | |||
| 90df7f5cc2 | |||
| d12a39329b | |||
| 6f419477df | |||
| a004f21688 | |||
| 065534819c | |||
| b0b389dc4c | |||
| bc131f6440 | |||
| ff7de70a61 | |||
| dbb7899ca3 | |||
| 7eeabc93aa | |||
| ac2315bc8e | |||
| de22dcf87f | |||
| e82f5fbead | |||
| d64365aa56 | |||
| 2d2a85f06f | |||
| b545d08939 | |||
| 9d11683920 | |||
| 15ffaefb87 | |||
| 32aec14c41 | |||
| f7d0369213 | |||
| 57e34aa104 | |||
| 48d5e15f7f | |||
| b78845a926 | |||
| c10a13f605 | |||
| 762d3e75cf | |||
| db5b2b1f82 | |||
| 685a7ffca1 | |||
| 84d1efc271 | |||
| b461343b23 | |||
| be353d53bd | |||
| 0a5bd4e47d | |||
| 11227d59e3 | |||
| 0f585ea5bb | |||
| a6ea291ea8 | |||
| 703f4d6ee2 | |||
| 86a902c197 | |||
| 36f5e9fb06 | |||
| 2b9ebe5522 | |||
| e4f1eead25 | |||
| 323d74cc66 | |||
| 81c1fc92d1 | |||
| 319ccc32ee | |||
| 1a0197812d | |||
| 589c60a325 | |||
| 38144b1033 | |||
| 00ccb9748e | |||
| a16ad0b977 | |||
| c2a21da47c | |||
| 94d3764c05 | |||
| 8f372d13f8 | |||
| 211ca9c46d | |||
| 2ec6ca827e | |||
| 41d38a70ed | |||
| b8d5cb9816 | |||
| c5883e5954 | |||
| 1ddd219449 | |||
| be6034afcf | |||
| 37ed70c7e9 | |||
| e4dc5f3f5d | |||
| 0058cc17b5 | |||
| 0754d986e9 | |||
| 6e230e250e | |||
| 4f299c6204 | |||
| 261ca98103 | |||
| 60f1e2c511 | |||
| f6bded1596 | |||
| 2f4b60574c | |||
| 29880d1cf9 | |||
| 9de127f879 | |||
| ae4e95312d | |||
| 093c25f3dd | |||
| 375c18873e | |||
| 3ee564ef70 | |||
| 4ca6ec2b58 | |||
| be529e53f6 | |||
| 592b4d3dad | |||
| d08e834f49 | |||
| 93c00d51a7 | |||
| 7df583a3b2 | |||
| 194ffe54db | |||
| f9622820c7 | |||
| 933efb1ff9 | |||
| 0bbc07e0b9 | |||
| 7da02d9e3a | |||
| b70c8ddd0e | |||
| 88ddbd2513 | |||
| 464aee7612 | |||
| e3929572c3 | |||
| 7ed1d02271 | |||
| aec466c6f3 | |||
| 8d3f550cef | |||
| e19cd6e8ba | |||
| 484a2e3aac | |||
| c70ec1c501 | |||
| 466f908f7c | |||
| 9e618280b1 | |||
| 62847fbc15 | |||
| fc535590eb | |||
| 5fa73fa8e3 | |||
| 074ff2791d | |||
| a0d1829c1b | |||
| ac02fd707e | |||
| 10a17c5443 | |||
| 13577693f2 | |||
| 0b616436cf | |||
| 926b6f7cbb | |||
| 31db7b2b4e | |||
| 4899afd45f | |||
| 8b6b76c2fa | |||
| 4285d8d74c | |||
| d8ce7f9dc1 | |||
| 8b7da8296c | |||
| e6773c6baf | |||
| 2c8dad08f6 | |||
| 7fecaf491a | |||
| 9c7a10aa39 | |||
| bbf532d282 | |||
| eada1e9979 | |||
| 2f88ad124d | |||
| a398127522 | |||
| 083b195462 | |||
| 0656ddfe52 | |||
| 056d9a7f47 | |||
| 8ed44cc352 | |||
| 52ebff6286 | |||
| 7c0ec9dd39 | |||
| e9a34a5bd8 | |||
| f9fc0d9905 | |||
| d7ca7b194d | |||
| 47cf84f20b | |||
| 2d3e64da92 | |||
| 2db6e63162 | |||
| f2b7034b51 | |||
| 4c6032bce0 | |||
| 2fdb155547 | |||
| 75bc3bcc73 | |||
| 1b773fdda0 | |||
| 019b4041a8 | |||
| 4aca4aaac6 | |||
| f9d46aabeb | |||
| 54ab282efc | |||
| f976c5ba92 | |||
| f5a94c0ced |
@@ -329,6 +329,8 @@ jobs:
|
|||||||
# (CE default): admin creates accounts / sends invites.
|
# (CE default): admin creates accounts / sends invites.
|
||||||
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
|
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
|
||||||
value: "true"
|
value: "true"
|
||||||
|
- name: OVERLEAF_LATEX_SHELL_ESCAPE
|
||||||
|
value: "true"
|
||||||
# (SMTP email vars are loaded below via envFrom.)
|
# (SMTP email vars are loaded below via envFrom.)
|
||||||
# SMTP for password-reset / invite emails. All
|
# SMTP for password-reset / invite emails. All
|
||||||
# OVERLEAF_EMAIL_* vars come from the optional 'verso-smtp'
|
# OVERLEAF_EMAIL_* vars come from the optional 'verso-smtp'
|
||||||
|
|||||||
@@ -300,6 +300,8 @@ jobs:
|
|||||||
# link-sharing users).
|
# link-sharing users).
|
||||||
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
|
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
|
||||||
value: "true"
|
value: "true"
|
||||||
|
- name: OVERLEAF_LATEX_SHELL_ESCAPE
|
||||||
|
value: "true"
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
|
|||||||
@@ -2,73 +2,182 @@
|
|||||||
<img src="services/web/public/img/ol-brand/verso-logo.svg" alt="Verso" width="440">
|
<img src="services/web/public/img/ol-brand/verso-logo.svg" alt="Verso" width="440">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**A collaborative, real-time editor for Quarto, LaTeX and Typst — documents and presentations.**
|
**A collaborative, real-time editor for LaTeX, Quarto and Typst — self-hosted.**
|
||||||
|
|
||||||
Verso is a fork of [Overleaf](https://github.com/overleaf/overleaf) that adds
|
---
|
||||||
first-class [Quarto](https://quarto.org) and [Typst](https://typst.app) support
|
|
||||||
alongside Overleaf's LaTeX toolchain. It keeps Overleaf's real-time
|
|
||||||
collaboration infrastructure and runs **three compilers side by side**, chosen
|
|
||||||
automatically from the root file's extension:
|
|
||||||
|
|
||||||
| Root file | Compiler | Typical output |
|
## What is Verso?
|
||||||
|-----------|----------|----------------|
|
|
||||||
| `.qmd` | Quarto | PDF (via Typst or LaTeX), or an HTML/RevealJS deck |
|
|
||||||
| `.tex` | `latexmk` / TeX Live | PDF |
|
|
||||||
| `.typ` | Typst | PDF |
|
|
||||||
|
|
||||||
All three coexist on one server; no per-project configuration is required to
|
Verso is a fork of [Overleaf](https://github.com/overleaf/overleaf) that extends its
|
||||||
pick the engine.
|
collaborative editing infrastructure to support [Quarto](https://quarto.org) and
|
||||||
|
[Typst](https://typst.app) projects alongside LaTeX. Think of it as Overleaf, but
|
||||||
|
not limited to LaTeX.
|
||||||
|
|
||||||
|
### Verso vs Overleaf
|
||||||
|
|
||||||
|
[Overleaf](https://www.overleaf.com) is the gold standard for collaborative LaTeX
|
||||||
|
editing. Verso keeps everything that makes Overleaf great — real-time co-editing,
|
||||||
|
operational-transformation history, auth, project management, file storage — and
|
||||||
|
adds:
|
||||||
|
|
||||||
|
- **Quarto and Typst compilers** running alongside TeX Live, dispatched
|
||||||
|
automatically from the root file's extension (`.qmd` → Quarto, `.typ` → Typst,
|
||||||
|
`.tex` → `latexmk`).
|
||||||
|
- **Language-aware editor** for Quarto and Typst (syntax highlighting, completions,
|
||||||
|
document outline) — not just LaTeX.
|
||||||
|
- **Publish & share compiled output** (`/p/:token` with tiered access links) — a
|
||||||
|
feature absent from Overleaf Community Edition.
|
||||||
|
- **Lumière theme** — a redesigned project dashboard and editor chrome with a
|
||||||
|
card-based grid, thumbnails, and a teal gradient identity.
|
||||||
|
- **Full i18n** — French, German, Italian, and Spanish UI translations on top of
|
||||||
|
Overleaf's English base.
|
||||||
|
- Completely **free and self-hosted**; no Overleaf subscription required.
|
||||||
|
|
||||||
|
### Verso vs Quarto
|
||||||
|
|
||||||
|
[Quarto](https://quarto.org) is a command-line tool: you install it locally, write
|
||||||
|
`.qmd` files in any text editor, and run `quarto render` in a terminal. It is
|
||||||
|
excellent for solo authors with full control over their environment.
|
||||||
|
|
||||||
|
Verso wraps Quarto in a collaborative web editor:
|
||||||
|
|
||||||
|
- **No local install** — Quarto, Typst, TeX Live and Python run on the server.
|
||||||
|
- **Real-time collaboration** — multiple people edit the same `.qmd` simultaneously
|
||||||
|
with live cursors and conflict-free merging.
|
||||||
|
- **Not just Quarto** — LaTeX and Typst projects live in the same workspace, under
|
||||||
|
the same auth and history system.
|
||||||
|
- **Publish in one click** — RevealJS decks and PDFs are served at a stable link
|
||||||
|
without leaving the browser.
|
||||||
|
|
||||||
|
Verso is not a replacement for Quarto's CLI — it is a platform that makes Quarto
|
||||||
|
accessible as a shared, always-on service.
|
||||||
|
|
||||||
|
### Verso vs Typst.app
|
||||||
|
|
||||||
|
[Typst.app](https://typst.app) is a cloud-hosted web editor for Typst. It is
|
||||||
|
polished and fast, but it is a proprietary SaaS product and only supports Typst.
|
||||||
|
|
||||||
|
Verso differs in that:
|
||||||
|
|
||||||
|
- It is **self-hosted** and open-source (AGPL v3) — you control your data.
|
||||||
|
- It supports **three languages** (Typst, LaTeX, Quarto) in one instance.
|
||||||
|
- Real-time collaboration is powered by **operational transformation** (the same
|
||||||
|
engine as Overleaf), not CRDTs, which means it handles concurrent edits
|
||||||
|
gracefully for long documents.
|
||||||
|
- It ships with a full **project history** and version-restore workflow.
|
||||||
|
|
||||||
|
If you only need Typst and want a lighter, Typst-focused alternative, have a look
|
||||||
|
at **[Collabst](https://github.com/herluf-ba/collabst)** — an open-source,
|
||||||
|
self-hosted collaborative Typst editor that is independent of the Overleaf
|
||||||
|
codebase and shows a lot of promise.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Real-time collaboration** — multiple people editing the same file at once,
|
- **Real-time collaboration** — multiple editors, live cursors, full project history and version restore.
|
||||||
powered by Overleaf's operational-transformation engine, with live cursors
|
- **Three compilers, auto-dispatched** by root file extension:
|
||||||
and full project history.
|
| Root file | Compiler | Typical output |
|
||||||
- **Three compilers, auto-dispatched** — Quarto, LaTeX and Typst projects live
|
|-----------|----------|----------------|
|
||||||
side by side; the runner is selected from the root file's extension.
|
| `.qmd` | Quarto | PDF (via Typst or LaTeX), HTML, or RevealJS |
|
||||||
- **Language-aware editor for all three**:
|
| `.tex` | `latexmk` / TeX Live | PDF |
|
||||||
- *LaTeX* — syntax highlighting, command/environment/reference autocomplete,
|
| `.typ` | Typst | PDF |
|
||||||
linting (inherited from Overleaf).
|
- **Language-aware editor for all three** — syntax highlighting, completions, and a document outline panel for LaTeX, Quarto and Typst.
|
||||||
- *Quarto (`.qmd`)* — Markdown highlighting plus Quarto-aware completions:
|
- **Format badge** on the project dashboard; compiler dropdown greys out inapplicable engines.
|
||||||
code chunks (```` ```{python} ````, `{r}`, `{julia}`, `{ojs}`…), callouts
|
- **Publish & share** — compile and snapshot to `/p/:token` with three independent access tiers (project members / any logged-in user / public). HTML/RevealJS decks are served live; PDFs are embedded inline. A **Present** toolbar button links directly to the published deck.
|
||||||
and fenced divs (`::: {.callout-note}`, columns, tabsets) and
|
- **RevealJS thumbnails** — the first slide of a presentation is rendered as a preview card in the project list.
|
||||||
cross-references (`@fig-`, `@tbl-`, `@sec-`, `@eq-`).
|
- **Quarto Python cells** — optional per-project virtual environment built from `requirements.txt`, so Python code chunks execute during render.
|
||||||
- *Typst (`.typ`)* — syntax highlighting and completions for the common
|
- **Visual formatting toolbar** — bold, italic, headings and inline code shortcuts for Quarto (`.qmd`) and Typst (`.typ`) files, in addition to Overleaf's existing LaTeX toolbar.
|
||||||
functions and markup (`#import`, `#let`, `#set`, `#show`, `#figure`,
|
- **Lumière theme** — card-based project dashboard with PDF/slide thumbnails, a teal gradient identity, dark editor chrome, and an XS compact list view.
|
||||||
`#table`, `#cite`, …).
|
- **i18n** — French, German, Italian and Spanish UI translations.
|
||||||
- **Document outline** — section headings are extracted into the sidebar
|
- **Auto-compile** — preview refreshes automatically after you stop typing.
|
||||||
outline panel for LaTeX, Quarto (`#`, `##`, …) and Typst (`=`, `==`, …).
|
|
||||||
- **Format at a glance** — the project dashboard shows a per-project format
|
|
||||||
badge (Quarto / Typst / LaTeX), and the compiler dropdown greys out engines
|
|
||||||
that don't apply to the current root file.
|
|
||||||
- **Publish & share compiled output** — publish the compiled result as a
|
|
||||||
standalone page at `/p/:token`, with three independent access tiers (project
|
|
||||||
members / any logged-in user / public). Works for both HTML/RevealJS decks
|
|
||||||
(served live) and PDFs (embedded inline). HTML decks also get a one-click
|
|
||||||
**Present** button in the toolbar.
|
|
||||||
- **Quarto Python cells** — optional per-project virtual environment built from
|
|
||||||
the project's `requirements.txt`, so Python code chunks run during render
|
|
||||||
(gated to the project owner and invited collaborators).
|
|
||||||
- **Auto-compile** — the preview refreshes automatically shortly after you stop
|
|
||||||
typing.
|
|
||||||
|
|
||||||
## Output formats
|
---
|
||||||
|
|
||||||
In the YAML frontmatter of a `.qmd` file:
|
## Releases
|
||||||
|
|
||||||
```yaml
|
### Alpha 1
|
||||||
format: typst # → PDF preview, rendered via Typst (no LaTeX required)
|
|
||||||
format: pdf # → PDF preview, rendered via LaTeX
|
|
||||||
format: revealjs # → interactive HTML slideshow preview
|
|
||||||
format: html # → a static HTML page
|
|
||||||
```
|
|
||||||
|
|
||||||
Typst ships inside Quarto, so `format: typst` needs no separate installation.
|
The initial public release. Established Verso as an Overleaf fork with first-class
|
||||||
|
multi-language support:
|
||||||
|
|
||||||
> **Note on display math**: keep `$$ … $$` blocks on a single line. Multi-line
|
- Quarto (`.qmd`) and Typst (`.typ`) compilers running alongside TeX Live,
|
||||||
> display-math blocks can trigger YAML parse errors in some Quarto versions.
|
dispatched automatically by root file extension — no per-project configuration.
|
||||||
|
- Language-aware editor for Quarto: Markdown highlighting, code-chunk completions
|
||||||
|
(`{python}`, `{r}`, `{julia}`, `{ojs}`…), callout and fenced-div completions,
|
||||||
|
cross-reference completions (`@fig-`, `@tbl-`, `@sec-`…).
|
||||||
|
- Language-aware editor for Typst: syntax highlighting and completions for
|
||||||
|
functions, imports, math and markup.
|
||||||
|
- Document outline panel for all three languages (LaTeX `\section`, Quarto `#`,
|
||||||
|
Typst `=`).
|
||||||
|
- Format badge on the project dashboard; compiler selector greys out inapplicable
|
||||||
|
engines for the current root file.
|
||||||
|
- Publish & share compiled output — HTML/RevealJS decks and PDFs hosted at
|
||||||
|
`/p/:token` with tiered access links (project / logged-in / public), each
|
||||||
|
independently resettable.
|
||||||
|
- Quarto Python code-cell execution via an optional per-project `requirements.txt`
|
||||||
|
virtual environment.
|
||||||
|
- Verso branding: name, logo and Kubernetes production deploy workflow.
|
||||||
|
|
||||||
|
### Alpha 2
|
||||||
|
|
||||||
|
Refinements to the Typst editor and the format badge system:
|
||||||
|
|
||||||
|
- **Quarto format sub-types** — the project badge now distinguishes *Quarto PDF*
|
||||||
|
from *Quarto Slides*, reading the frontmatter `format:` to pick the right label.
|
||||||
|
- **Python packages for collaborators** — Quarto Python package installation
|
||||||
|
extended to all users who have write access to the project, not only the owner.
|
||||||
|
- **Typst syntax highlighting overhaul** — complete grammar rewrite covering:
|
||||||
|
function calls and named argument keys, multi-line display math, `#{…}` code
|
||||||
|
blocks, content blocks, `show`-rule bodies, `let`-value bindings, and keyword
|
||||||
|
vs identifier disambiguation.
|
||||||
|
- **Typst visual formatting** — bold and italic toolbar buttons and keyboard
|
||||||
|
shortcuts (`Ctrl+B`, `Ctrl+I`), plus underline, small-caps and hyperlink
|
||||||
|
buttons, matching the Quarto and LaTeX toolbar experience.
|
||||||
|
|
||||||
|
### Alpha 3
|
||||||
|
|
||||||
|
- **Lumière theme** — redesigned project dashboard with a card grid, PDF/slide
|
||||||
|
thumbnails, parallax hover effects, a teal gradient identity and a dark editor
|
||||||
|
chrome. Includes an XS compact list view and a tile zoom slider.
|
||||||
|
- **Full i18n** — French, German, Italian and Spanish translations covering the
|
||||||
|
complete UI (login, dashboard, editor, settings, emails).
|
||||||
|
- **Visual editors for Quarto and Typst** — bold, italic, headings and inline code
|
||||||
|
shortcuts in the toolbar for `.qmd` and `.typ` files.
|
||||||
|
- **Top/bottom split view** — new editor layout that stacks the source editor
|
||||||
|
above the PDF preview vertically, in addition to the existing side-by-side mode.
|
||||||
|
- **Bidirectional format export** — LaTeX projects can be converted to Typst and
|
||||||
|
Typst projects to LaTeX via pandoc. Available from the File menu in the editor.
|
||||||
|
- **Mobile layout** — project dashboard, search bar, footer and editor all
|
||||||
|
adapted for phone screen sizes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known issues
|
||||||
|
|
||||||
|
- **Large file upload timeouts** — uploads of large files on slow connections
|
||||||
|
can time out at the proxy layer. A streaming response fix is pending.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security model — trusted environments only
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> Verso is designed for **closed groups of trusted users** (a lab, a class, a
|
||||||
|
> small team). All three compilers can execute arbitrary code on the server:
|
||||||
|
>
|
||||||
|
> - LaTeX with shell-escape enabled can run system commands.
|
||||||
|
> - Quarto Python cells execute Python code directly.
|
||||||
|
> - Typst's scripting layer is sandboxed by design, but runs server-side.
|
||||||
|
>
|
||||||
|
> There is **no per-project sandbox or resource isolation** beyond what the
|
||||||
|
> operating system provides. Exposing Verso to the public internet with open
|
||||||
|
> registration is not recommended. If you need to host a collaborative
|
||||||
|
> LaTeX editor for untrusted users or at scale, look at
|
||||||
|
> [Overleaf's non-Community offerings](https://www.overleaf.com/for/enterprises),
|
||||||
|
> which include proper sandboxing and enterprise access controls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -82,24 +191,23 @@ docker run -d \
|
|||||||
registry.alocoq.fr/verso:latest
|
registry.alocoq.fr/verso:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost` in your browser, then visit `/launchpad` on first run to
|
Open `http://localhost`, then visit `/launchpad` on first run to create the admin
|
||||||
create the admin account.
|
account.
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the base image (system deps + Quarto + TeX Live)
|
|
||||||
cd server-ce
|
cd server-ce
|
||||||
make build-base
|
make build-base # base OS image: system deps, Quarto, Typst, TeX Live
|
||||||
|
make build-community # application image: Node services + compiled frontend
|
||||||
# Build the application image
|
|
||||||
make build-community
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `server-ce/Dockerfile-base` | Base OS image — system deps, Quarto (with Typst) and a TeX Live (`latexmk`) toolchain |
|
| `server-ce/Dockerfile-base` | Base image — system deps, Quarto (with Typst) and TeX Live |
|
||||||
| `server-ce/Dockerfile` | Application image — Node services and the compiled frontend |
|
| `server-ce/Dockerfile` | App image — Node services and the compiled React frontend |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -108,10 +216,10 @@ single container managed by `runit`, with `nginx` as the front router.
|
|||||||
|
|
||||||
```
|
```
|
||||||
browser ──→ nginx:80
|
browser ──→ nginx:80
|
||||||
├── / ──────────────────→ web:4000 (main app, React UI)
|
├── / ──────────────────→ web:4000 (main app, React UI)
|
||||||
├── /socket.io ──────────→ real-time:3026 (WebSocket, OT engine)
|
├── /socket.io ──────────→ real-time:3026 (WebSocket, OT engine)
|
||||||
├── /p/:token ───────────→ web (published output)
|
├── /p/:token ───────────→ web (published output)
|
||||||
└── /project/*/output/* → clsi-nginx:8080 (compiled output files)
|
└── /project/*/output/* → clsi-nginx:8080 (compiled output files)
|
||||||
|
|
||||||
web → document-updater → Redis pub/sub → real-time → browser
|
web → document-updater → Redis pub/sub → real-time → browser
|
||||||
web → CLSI (quarto render / latexmk / typst) → output files → nginx → browser
|
web → CLSI (quarto render / latexmk / typst) → output files → nginx → browser
|
||||||
@@ -122,64 +230,12 @@ web → CLSI (quarto render / latexmk / typst) → output files → nginx → br
|
|||||||
| `web` | HTTP API, React frontend, auth, project & sharing management |
|
| `web` | HTTP API, React frontend, auth, project & sharing management |
|
||||||
| `real-time` | WebSocket layer, live cursors and edit sync |
|
| `real-time` | WebSocket layer, live cursors and edit sync |
|
||||||
| `document-updater` | Operational transformation, Redis pub/sub |
|
| `document-updater` | Operational transformation, Redis pub/sub |
|
||||||
| `clsi` | Compiler — runs `quarto render` (`.qmd`), `latexmk` (`.tex`) or `typst` (`.typ`) and serves output |
|
| `clsi` | Compiler — runs `quarto render`, `latexmk` or `typst` and serves output |
|
||||||
| `docstore` | Document text storage (MongoDB) |
|
| `docstore` | Document text storage (MongoDB) |
|
||||||
| `filestore` | Binary file storage (S3 or local) |
|
| `filestore` | Binary file storage (S3 or local) |
|
||||||
| `project-history` | Change history and version tracking |
|
| `project-history` | Change history and version tracking |
|
||||||
|
|
||||||
## Writing documents
|
|
||||||
|
|
||||||
### Quarto (`main.qmd`)
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
---
|
||||||
title: My Presentation
|
|
||||||
author: Your Name
|
|
||||||
date: today
|
|
||||||
format: revealjs
|
|
||||||
---
|
|
||||||
|
|
||||||
## Slide one
|
|
||||||
|
|
||||||
Write **Markdown** here.
|
|
||||||
|
|
||||||
## Mathematics
|
|
||||||
|
|
||||||
$$\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}$$
|
|
||||||
```
|
|
||||||
|
|
||||||
Switch `format: revealjs` to `format: typst` (or `pdf`) for a PDF preview.
|
|
||||||
|
|
||||||
### LaTeX (`main.tex`)
|
|
||||||
|
|
||||||
LaTeX works exactly as in Overleaf: a project whose root file is a `.tex` file
|
|
||||||
compiles with `latexmk`/TeX Live, no setting required. The **Example LaTeX
|
|
||||||
project** in the *New project* menu is a ready-made starting point.
|
|
||||||
|
|
||||||
> The bundled TeX Live is a minimal install. Documents that need extra packages
|
|
||||||
> may not build out of the box — see `server-ce/Dockerfile-base` for how to
|
|
||||||
> switch to a fuller TeX Live scheme.
|
|
||||||
|
|
||||||
### Typst (`main.typ`)
|
|
||||||
|
|
||||||
A project whose root file is a `.typ` file compiles directly to PDF with
|
|
||||||
[Typst](https://typst.app) — fast, modern markup with a real scripting
|
|
||||||
language. Verso drives the Typst bundled with Quarto, so no extra install is
|
|
||||||
needed. Use the **Blank Typst project** entry in the *New project* menu to get
|
|
||||||
started.
|
|
||||||
|
|
||||||
## Publishing compiled output
|
|
||||||
|
|
||||||
From **Share → Publish**, Verso compiles the project and snapshots the result to
|
|
||||||
a standalone page at `/p/:token`:
|
|
||||||
|
|
||||||
- **HTML / RevealJS** decks are served as a live page (the **Present** toolbar
|
|
||||||
button is a one-click shortcut to this).
|
|
||||||
- **PDF** output is embedded inline; the raw file stays reachable at
|
|
||||||
`/p/:token/output.pdf`.
|
|
||||||
|
|
||||||
Three stable links are issued, one per access tier — project members, any
|
|
||||||
logged-in user, or anyone — and each can be copied or independently reset.
|
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
@@ -195,39 +251,46 @@ The most commonly needed:
|
|||||||
| `OVERLEAF_SITE_URL` | — | Public URL (used in emails and published links) |
|
| `OVERLEAF_SITE_URL` | — | Public URL (used in emails and published links) |
|
||||||
| `OVERLEAF_SITE_LANGUAGE` | `en` | Default UI language (e.g. `fr`) |
|
| `OVERLEAF_SITE_LANGUAGE` | `en` | Default UI language (e.g. `fr`) |
|
||||||
| `OVERLEAF_ENABLE_PROJECT_PYTHON_VENV` | `false` | Allow Quarto Python cells to use a project `requirements.txt` |
|
| `OVERLEAF_ENABLE_PROJECT_PYTHON_VENV` | `false` | Allow Quarto Python cells to use a project `requirements.txt` |
|
||||||
| `OVERLEAF_ADMIN_EMAIL` | — | Email for the first admin account |
|
| `OVERLEAF_ADMIN_EMAIL` | — | Email shown on the launchpad for the first admin account |
|
||||||
|
|
||||||
See the [Overleaf Server documentation](https://github.com/overleaf/overleaf/wiki)
|
See the [Overleaf Server documentation](https://github.com/overleaf/overleaf/wiki)
|
||||||
for the full list.
|
for the full list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Relation to Overleaf
|
## Relation to Overleaf
|
||||||
|
|
||||||
Verso is a fork of [Overleaf Community Edition](https://github.com/overleaf/overleaf).
|
Verso is a fork of [Overleaf Community Edition](https://github.com/overleaf/overleaf).
|
||||||
The main additions on top of upstream are:
|
Everything that Overleaf CE provides — real-time collaboration, operational-transformation
|
||||||
|
history, auth, project management, binary file storage — is inherited unchanged. The
|
||||||
|
Verso-specific additions are listed in the Features section and tracked across releases above.
|
||||||
|
|
||||||
- Quarto and Typst compilers running alongside LaTeX, dispatched by the root
|
Verso is not affiliated with Overleaf Ltd.
|
||||||
file's extension.
|
|
||||||
- Editor language support (highlighting, autocomplete, outline) for Quarto and
|
|
||||||
Typst.
|
|
||||||
- A per-project format badge on the dashboard and a root-file-aware compiler
|
|
||||||
selector.
|
|
||||||
- Publishing/sharing of compiled output (HTML decks and PDFs) via `/p/:token`
|
|
||||||
with tiered access links, and a toolbar **Present** shortcut.
|
|
||||||
- Optional per-project Python virtual environments for Quarto code execution.
|
|
||||||
- Verso branding (name, logo, palette, loading animation).
|
|
||||||
|
|
||||||
All other infrastructure — real-time collaboration, history, auth, file
|
---
|
||||||
storage, project management — is unchanged from Overleaf.
|
|
||||||
|
|
||||||
## Contributing
|
## Supporting the ecosystem
|
||||||
|
|
||||||
Contributions are welcome — open an issue or pull request on the
|
Verso is not accepting contributions or donations at this time. If you find it
|
||||||
[Verso repository](https://git.alocoq.fr/alois/verso). The upstream Overleaf
|
useful and want to support the broader ecosystem it builds on:
|
||||||
contribution guidelines are in [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
||||||
|
- **Support Overleaf** — Verso is built on Overleaf's infrastructure. The best
|
||||||
|
way to support their work is to use or subscribe to
|
||||||
|
[Overleaf](https://www.overleaf.com) and encourage your institution to do the
|
||||||
|
same.
|
||||||
|
- **Support Typst** — [Typst GmbH](https://typst.app) is the company behind the
|
||||||
|
Typst compiler. Using Typst.app or sponsoring the
|
||||||
|
[Typst project on GitHub](https://github.com/typst/typst) helps sustain the
|
||||||
|
language itself.
|
||||||
|
- **Support RevealJS** — Verso uses [Reveal.js](https://revealjs.com) for
|
||||||
|
HTML presentations. Consider sponsoring the
|
||||||
|
[RevealJS project on GitHub](https://github.com/hakimel/reveal.js).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
GNU Affero General Public License v3 — see [LICENSE](LICENSE).
|
GNU Affero General Public License v3 — see [LICENSE](LICENSE).
|
||||||
|
|
||||||
Copyright © Overleaf, 2014–2026 (original code).
|
Copyright © Overleaf, 2014–2026 (original code).
|
||||||
Verso modifications © Aloïs Coquillard, 2026.
|
Verso modifications © Aloïs Coquillard, 2026.
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ RUN --mount=type=cache,target=/root/.cache \
|
|||||||
# Add the actual source files
|
# Add the actual source files
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
COPY --parents libraries/ services/ tools/migrations/ /overleaf/
|
COPY --parents libraries/ services/ tools/migrations/ /overleaf/
|
||||||
|
|
||||||
|
# Syntax-check all server-side ESM modules before the expensive webpack
|
||||||
|
# compile. node --check parses without executing, so it's fast and safe.
|
||||||
|
# Catches things like escaped backticks from sed substitutions that webpack
|
||||||
|
# never sees (it only bundles frontend code).
|
||||||
|
RUN find services/web/app/src services/web/modules -name '*.mjs' | xargs node --check
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache \
|
RUN --mount=type=cache,target=/root/.cache \
|
||||||
--mount=type=cache,target=/root/.yarn/berry/cache,id=server-ce-yarn-cache \
|
--mount=type=cache,target=/root/.yarn/berry/cache,id=server-ce-yarn-cache \
|
||||||
--mount=type=tmpfs,target=/usr/local/share/.cache/yarn \
|
--mount=type=tmpfs,target=/usr/local/share/.cache/yarn \
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
|||||||
unattended-upgrades \
|
unattended-upgrades \
|
||||||
build-essential wget net-tools unzip time poppler-utils optipng strace nginx git python3 python-is-python3 zlib1g-dev libpcre3-dev gettext-base libwww-perl ca-certificates curl gnupg \
|
build-essential wget net-tools unzip time poppler-utils optipng strace nginx git python3 python-is-python3 zlib1g-dev libpcre3-dev gettext-base libwww-perl ca-certificates curl gnupg \
|
||||||
qpdf \
|
qpdf \
|
||||||
|
pandoc \
|
||||||
# upgrade base-image, batch all the upgrades together, rather than installing them on-by-one (which is slow!)
|
# upgrade base-image, batch all the upgrades together, rather than installing them on-by-one (which is slow!)
|
||||||
&& unattended-upgrade --verbose --no-minimal-upgrade-steps \
|
&& unattended-upgrade --verbose --no-minimal-upgrade-steps \
|
||||||
# install Node.js https://github.com/nodesource/distributions#nodejs
|
# install Node.js https://github.com/nodesource/distributions#nodejs
|
||||||
@@ -104,6 +105,15 @@ RUN apt-get update \
|
|||||||
opencv-python-headless tqdm \
|
opencv-python-headless tqdm \
|
||||||
&& rm -rf /var/lib/apt/lists/* /root/.cache
|
&& rm -rf /var/lib/apt/lists/* /root/.cache
|
||||||
|
|
||||||
|
# Install Inkscape (for the LaTeX svg package via shell-escape)
|
||||||
|
# Must come AFTER the pip installs above: inkscape pulls in python3-numpy via
|
||||||
|
# apt, which would block pip from upgrading numpy. With pip's numpy already in
|
||||||
|
# /usr/local/lib, apt installs its own copy into /usr/lib alongside it — no
|
||||||
|
# conflict — and Python resolves /usr/local/lib first at import time.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y inkscape \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install decktape + headless Chromium (for exporting RevealJS decks to PDF)
|
# Install decktape + headless Chromium (for exporting RevealJS decks to PDF)
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# decktape drives a headless Chromium (via Puppeteer) to print the rendered
|
# decktape drives a headless Chromium (via Puppeteer) to print the rendered
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export ENABLE_PANDOC_CONVERSIONS=true
|
||||||
export CHAT_HOST=127.0.0.1
|
export CHAT_HOST=127.0.0.1
|
||||||
export CLSI_HOST=127.0.0.1
|
export CLSI_HOST=127.0.0.1
|
||||||
export DOCSTORE_HOST=127.0.0.1
|
export DOCSTORE_HOST=127.0.0.1
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const TMP_DIR = '/var/lib/overleaf/tmp'
|
|||||||
const settings = {
|
const settings = {
|
||||||
clsi: {
|
clsi: {
|
||||||
optimiseInDocker: process.env.OPTIMISE_PDF === 'true',
|
optimiseInDocker: process.env.OPTIMISE_PDF === 'true',
|
||||||
|
latexShellEscape: process.env.OVERLEAF_LATEX_SHELL_ESCAPE === 'true',
|
||||||
},
|
},
|
||||||
|
|
||||||
brandPrefix: '',
|
brandPrefix: '',
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ server {
|
|||||||
rewrite ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ /$4 break;
|
rewrite ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ /$4 break;
|
||||||
root /var/lib/overleaf/data/output/$1-$2/generated-files/$3/;
|
root /var/lib/overleaf/data/output/$1-$2/generated-files/$3/;
|
||||||
}
|
}
|
||||||
# handle output files for anonymous users
|
# handle output files for anonymous users (project ID may be a UUID with hyphens for conversions)
|
||||||
location ~ ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ {
|
location ~ ^/project/([0-9a-f-]+)/build/([0-9a-f-]+)/output/(.+)$ {
|
||||||
rewrite ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ /$3 break;
|
rewrite ^/project/([0-9a-f-]+)/build/([0-9a-f-]+)/output/(.+)$ /$3 break;
|
||||||
root /var/lib/overleaf/data/output/$1/generated-files/$2/;
|
root /var/lib/overleaf/data/output/$1/generated-files/$2/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ http {
|
|||||||
gzip_proxied any; # allow upstream server to compress.
|
gzip_proxied any; # allow upstream server to compress.
|
||||||
|
|
||||||
client_max_body_size 500m;
|
client_max_body_size 500m;
|
||||||
|
client_body_timeout 15m;
|
||||||
|
|
||||||
# gzip_vary on;
|
# gzip_vary on;
|
||||||
# gzip_proxied any;
|
# gzip_proxied any;
|
||||||
|
|||||||
@@ -9,6 +9,26 @@ server {
|
|||||||
internal;
|
internal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# File upload endpoints: extended timeouts for large files on slow connections.
|
||||||
|
# proxy_request_buffering off: forward the request body to Node.js immediately
|
||||||
|
# rather than buffering first, so Node.js can send a keepalive response byte
|
||||||
|
# before the full body arrives (preventing upstream proxy "first-byte" timeouts).
|
||||||
|
# proxy_buffering off: forward that keepalive byte to Traefik/LB without delay.
|
||||||
|
location ~ ^/project/[^/]+/upload$ {
|
||||||
|
proxy_pass http://127.0.0.1:4000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 15m;
|
||||||
|
proxy_send_timeout 15m;
|
||||||
|
send_timeout 15m;
|
||||||
|
client_body_timeout 15m;
|
||||||
|
client_max_body_size 550m;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:4000;
|
proxy_pass http://127.0.0.1:4000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -150,6 +150,10 @@ app.post(
|
|||||||
FileUploadMiddleware.multerMiddleware,
|
FileUploadMiddleware.multerMiddleware,
|
||||||
ConversionController.convertPDFToJPEG
|
ConversionController.convertPDFToJPEG
|
||||||
)
|
)
|
||||||
|
app.get(
|
||||||
|
'/project/:project_id/user/:user_id/build/:build_id/thumbnail',
|
||||||
|
ConversionController.thumbnailFromBuild
|
||||||
|
)
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development' && global.__coverage__) {
|
if (process.env.NODE_ENV === 'development' && global.__coverage__) {
|
||||||
app.get('/coverage', (req, res) => {
|
app.get('/coverage', (req, res) => {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import os from 'node:os'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
import logger from '@overleaf/logger'
|
import logger from '@overleaf/logger'
|
||||||
import { expressify } from '@overleaf/promise-utils'
|
import { expressify } from '@overleaf/promise-utils'
|
||||||
import fs from 'node:fs/promises'
|
import fs from 'node:fs/promises'
|
||||||
@@ -16,10 +19,14 @@ import Settings from '@overleaf/settings'
|
|||||||
import Path from 'node:path'
|
import Path from 'node:path'
|
||||||
import { z } from '@overleaf/validation-tools'
|
import { z } from '@overleaf/validation-tools'
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
const CONVERSION_CONFIGS = {
|
const CONVERSION_CONFIGS = {
|
||||||
docx: { extension: 'docx' },
|
docx: { extension: 'docx' },
|
||||||
markdown: { extension: 'zip' },
|
markdown: { extension: 'zip' },
|
||||||
html: { extension: 'zip' },
|
html: { extension: 'zip' },
|
||||||
|
typst: { extension: 'typ' },
|
||||||
|
latex: { extension: 'tex' },
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertDocumentToLaTeX(req, res) {
|
async function convertDocumentToLaTeX(req, res) {
|
||||||
@@ -29,7 +36,7 @@ async function convertDocumentToLaTeX(req, res) {
|
|||||||
await fs.unlink(path).catch(() => {})
|
await fs.unlink(path).catch(() => {})
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
if (!conversionType || !['docx', 'markdown'].includes(conversionType)) {
|
if (!conversionType || !['docx', 'markdown', 'typst'].includes(conversionType)) {
|
||||||
await fs.unlink(path).catch(() => {})
|
await fs.unlink(path).catch(() => {})
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
@@ -251,8 +258,117 @@ async function convertProjectToDocument(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generates a JPEG thumbnail of page 1 of the compiled output using
|
||||||
|
// pdftocairo (poppler-utils). Tries output.pdf first (LaTeX / Quarto-PDF),
|
||||||
|
// then output-slides.pdf (Quarto RevealJS after a PDF-export compile), then
|
||||||
|
// falls back to rendering slide 1 of output.html via decktape (normal
|
||||||
|
// RevealJS preview compile). All temp dirs are cleaned up in finally.
|
||||||
|
async function thumbnailFromBuild(req, res) {
|
||||||
|
const { project_id: projectId, user_id: userId, build_id: buildId } = req.params
|
||||||
|
if (!buildId?.match(OutputCacheManager.BUILD_REGEX)) return res.sendStatus(400)
|
||||||
|
|
||||||
|
const compileName = userId ? `${projectId}-${userId}` : projectId
|
||||||
|
const buildDir = Path.join(
|
||||||
|
Settings.path.outputDir,
|
||||||
|
compileName,
|
||||||
|
OutputCacheManager.CACHE_SUBDIR,
|
||||||
|
buildId
|
||||||
|
)
|
||||||
|
|
||||||
|
let pdfPath = null
|
||||||
|
let deckTapeDir = null
|
||||||
|
|
||||||
|
for (const name of ['output.pdf', 'output-slides.pdf']) {
|
||||||
|
try {
|
||||||
|
const p = Path.join(buildDir, name)
|
||||||
|
await fs.access(p)
|
||||||
|
pdfPath = p
|
||||||
|
break
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pdfPath) {
|
||||||
|
const htmlPath = Path.join(buildDir, 'output.html')
|
||||||
|
try {
|
||||||
|
await fs.access(htmlPath)
|
||||||
|
deckTapeDir = await fs.mkdtemp(Path.join(os.tmpdir(), 'clsi-deck-'))
|
||||||
|
const chromeHome = Path.join(deckTapeDir, 'chrome')
|
||||||
|
await fs.mkdir(chromeHome, { recursive: true })
|
||||||
|
const slidePdf = Path.join(deckTapeDir, 'slide1.pdf')
|
||||||
|
await execFileAsync(
|
||||||
|
'decktape',
|
||||||
|
[
|
||||||
|
'--slides', '1',
|
||||||
|
'--chrome-arg=--no-sandbox',
|
||||||
|
'--chrome-arg=--disable-dev-shm-usage',
|
||||||
|
'--chrome-arg=--disable-gpu',
|
||||||
|
`--chrome-arg=--user-data-dir=${chromeHome}/data`,
|
||||||
|
htmlPath,
|
||||||
|
slidePdf,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
timeout: 60000,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
HOME: chromeHome,
|
||||||
|
XDG_CONFIG_HOME: chromeHome,
|
||||||
|
XDG_CACHE_HOME: chromeHome,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
pdfPath = slidePdf
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, projectId, buildId }, 'decktape slide1 thumbnail failed')
|
||||||
|
if (deckTapeDir) {
|
||||||
|
await fs.rm(deckTapeDir, { recursive: true, force: true }).catch(() => {})
|
||||||
|
deckTapeDir = null
|
||||||
|
}
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpDir = await fs.mkdtemp(Path.join(os.tmpdir(), 'clsi-thumb-'))
|
||||||
|
const outputBase = Path.join(tmpDir, 'thumb')
|
||||||
|
const jpegPath = outputBase + '.jpg'
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execFileAsync(
|
||||||
|
'pdftocairo',
|
||||||
|
[
|
||||||
|
'-jpeg',
|
||||||
|
'-jpegopt', 'quality=90',
|
||||||
|
'-singlefile',
|
||||||
|
'-scale-to-x', '794',
|
||||||
|
'-scale-to-y', '-1',
|
||||||
|
'-f', '1',
|
||||||
|
'-l', '1',
|
||||||
|
pdfPath,
|
||||||
|
outputBase,
|
||||||
|
],
|
||||||
|
{ timeout: 30000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
const jpegStat = await fs.stat(jpegPath)
|
||||||
|
res.setHeader('Content-Type', 'image/jpeg')
|
||||||
|
res.setHeader('Content-Length', jpegStat.size)
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||||
|
const readStream = fsSync.createReadStream(jpegPath)
|
||||||
|
await pipeline(readStream, res)
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, projectId, buildId }, 'thumbnail generation failed')
|
||||||
|
if (!res.headersSent) res.sendStatus(500)
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
|
||||||
|
if (deckTapeDir) {
|
||||||
|
await fs.rm(deckTapeDir, { recursive: true, force: true }).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
convertDocumentToLaTeX: expressify(convertDocumentToLaTeX),
|
convertDocumentToLaTeX: expressify(convertDocumentToLaTeX),
|
||||||
convertProjectToDocument: expressify(convertProjectToDocument),
|
convertProjectToDocument: expressify(convertProjectToDocument),
|
||||||
convertPDFToJPEG: expressify(convertPDFToJPEG),
|
convertPDFToJPEG: expressify(convertPDFToJPEG),
|
||||||
|
thumbnailFromBuild: expressify(thumbnailFromBuild),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ const CONVERSION_CONFIGS = {
|
|||||||
inputFilename: 'input.md',
|
inputFilename: 'input.md',
|
||||||
pandocArgs: ['--from', 'markdown'],
|
pandocArgs: ['--from', 'markdown'],
|
||||||
},
|
},
|
||||||
|
typst: {
|
||||||
|
inputFilename: 'input.typ',
|
||||||
|
pandocArgs: ['--from', 'typst'],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const PDF_TO_JPEG_CONFIGS = {
|
const PDF_TO_JPEG_CONFIGS = {
|
||||||
preview: { width: 794, quality: 90 },
|
preview: { width: 794, quality: 90 },
|
||||||
thumbnail: { width: 190, quality: 50 },
|
thumbnail: { width: 794, quality: 90 },
|
||||||
}
|
}
|
||||||
|
|
||||||
const PDF_TO_JPEG_INPUT_FILENAME = 'input.pdf'
|
const PDF_TO_JPEG_INPUT_FILENAME = 'input.pdf'
|
||||||
@@ -175,6 +179,31 @@ const LATEX_EXPORT_CONFIGS = {
|
|||||||
'--standalone',
|
'--standalone',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
typst: {
|
||||||
|
fileExtension: 'typ',
|
||||||
|
compressOutput: false,
|
||||||
|
getPandocArgs: ({ outputPath }) => [
|
||||||
|
'--output',
|
||||||
|
outputPath,
|
||||||
|
'--from',
|
||||||
|
'latex',
|
||||||
|
'--to',
|
||||||
|
'typst',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
latex: {
|
||||||
|
fileExtension: 'tex',
|
||||||
|
compressOutput: false,
|
||||||
|
getPandocArgs: ({ outputPath }) => [
|
||||||
|
'--output',
|
||||||
|
outputPath,
|
||||||
|
'--from',
|
||||||
|
'typst',
|
||||||
|
'--to',
|
||||||
|
'latex',
|
||||||
|
'--standalone',
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertLaTeXToDocumentInDirWithLock(
|
async function convertLaTeXToDocumentInDirWithLock(
|
||||||
|
|||||||
@@ -197,6 +197,10 @@ function _buildLatexCommand(mainFile, opts = {}) {
|
|||||||
command.push(...opts.flags)
|
command.push(...opts.flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Settings.clsi?.latexShellEscape) {
|
||||||
|
command.push('-shell-escape')
|
||||||
|
}
|
||||||
|
|
||||||
// TeX Engine selection. A .tex project may carry a non-LaTeX compiler value
|
// TeX Engine selection. A .tex project may carry a non-LaTeX compiler value
|
||||||
// (e.g. 'quarto', the fork-wide default for Project.compiler) because the
|
// (e.g. 'quarto', the fork-wide default for Project.compiler) because the
|
||||||
// runner is chosen by file extension, not by this setting. In that case fall
|
// runner is chosen by file extension, not by this setting. In that case fall
|
||||||
|
|||||||
@@ -26,13 +26,12 @@ cypress/results/
|
|||||||
# Ace themes for conversion
|
# Ace themes for conversion
|
||||||
frontend/js/features/source-editor/themes/ace/
|
frontend/js/features/source-editor/themes/ace/
|
||||||
|
|
||||||
# Compiled parser files
|
# Compiled parser files (latex/bibtex are generated by webpack plugin at build time)
|
||||||
frontend/js/features/source-editor/lezer-latex/latex.mjs
|
frontend/js/features/source-editor/lezer-latex/latex.mjs
|
||||||
frontend/js/features/source-editor/lezer-latex/latex.terms.mjs
|
frontend/js/features/source-editor/lezer-latex/latex.terms.mjs
|
||||||
frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs
|
frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs
|
||||||
frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs
|
frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs
|
||||||
frontend/js/features/source-editor/lezer-typst/typst.mjs
|
# typst compiled files are committed (generated via node scripts/lezer-latex/generate.mjs)
|
||||||
frontend/js/features/source-editor/lezer-typst/typst.terms.mjs
|
|
||||||
|
|
||||||
!**/fixtures/**/*.log
|
!**/fixtures/**/*.log
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { expressify, promisify } from '@overleaf/promise-utils'
|
|||||||
import { handleAuthenticateErrors } from './AuthenticationErrors.mjs'
|
import { handleAuthenticateErrors } from './AuthenticationErrors.mjs'
|
||||||
import EmailHelper from '../Helpers/EmailHelper.mjs'
|
import EmailHelper from '../Helpers/EmailHelper.mjs'
|
||||||
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
|
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
|
||||||
|
import Translations from '../../infrastructure/Translations.mjs'
|
||||||
|
|
||||||
const { hasAdminAccess } = AdminAuthorizationHelper
|
const { hasAdminAccess } = AdminAuthorizationHelper
|
||||||
|
|
||||||
@@ -221,6 +222,17 @@ const AuthenticationController = {
|
|||||||
|
|
||||||
await _afterLoginSessionSetupAsync(req, user)
|
await _afterLoginSessionSetupAsync(req, user)
|
||||||
|
|
||||||
|
// Sync user's stored language preference to cookie on login
|
||||||
|
if (user.languageCode) {
|
||||||
|
res.cookie(Translations.LANG_COOKIE_NAME, user.languageCode, {
|
||||||
|
maxAge: 365 * 24 * 60 * 60 * 1000,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: Settings.secureCookie,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
domain: Settings.cookieDomain,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
AuthenticationController._clearRedirectFromSession(req)
|
AuthenticationController._clearRedirectFromSession(req)
|
||||||
AnalyticsRegistrationSourceHelper.clearSource(req.session)
|
AnalyticsRegistrationSourceHelper.clearSource(req.session)
|
||||||
AnalyticsRegistrationSourceHelper.clearInbound(req.session)
|
AnalyticsRegistrationSourceHelper.clearInbound(req.session)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { pipeline } from 'node:stream/promises'
|
import { pipeline } from 'node:stream/promises'
|
||||||
import Metrics from '@overleaf/metrics'
|
import Metrics from '@overleaf/metrics'
|
||||||
import ProjectGetter from '../Project/ProjectGetter.mjs'
|
import ProjectGetter from '../Project/ProjectGetter.mjs'
|
||||||
|
import { Project } from '../../models/Project.mjs'
|
||||||
import CompileManager from './CompileManager.mjs'
|
import CompileManager from './CompileManager.mjs'
|
||||||
import ClsiManager from './ClsiManager.mjs'
|
import ClsiManager from './ClsiManager.mjs'
|
||||||
import logger from '@overleaf/logger'
|
import logger from '@overleaf/logger'
|
||||||
@@ -8,6 +9,7 @@ import Settings from '@overleaf/settings'
|
|||||||
import Errors from '../Errors/Errors.js'
|
import Errors from '../Errors/Errors.js'
|
||||||
import SessionManager from '../Authentication/SessionManager.mjs'
|
import SessionManager from '../Authentication/SessionManager.mjs'
|
||||||
import { userCanInstallPython } from './PythonVenvGate.mjs'
|
import { userCanInstallPython } from './PythonVenvGate.mjs'
|
||||||
|
import TokenAccessHandler from '../TokenAccess/TokenAccessHandler.mjs'
|
||||||
import { RateLimiter } from '../../infrastructure/RateLimiter.mjs'
|
import { RateLimiter } from '../../infrastructure/RateLimiter.mjs'
|
||||||
import Validation from '../../infrastructure/Validation.mjs'
|
import Validation from '../../infrastructure/Validation.mjs'
|
||||||
import Path from 'node:path'
|
import Path from 'node:path'
|
||||||
@@ -18,6 +20,7 @@ import {
|
|||||||
fetchStreamWithResponse,
|
fetchStreamWithResponse,
|
||||||
RequestFailedError,
|
RequestFailedError,
|
||||||
} from '@overleaf/fetch-utils'
|
} from '@overleaf/fetch-utils'
|
||||||
|
import ThumbnailManager from './ThumbnailManager.mjs'
|
||||||
import Features from '../../infrastructure/Features.mjs'
|
import Features from '../../infrastructure/Features.mjs'
|
||||||
import ClsiCacheController from './ClsiCacheController.mjs'
|
import ClsiCacheController from './ClsiCacheController.mjs'
|
||||||
import { prepareZipAttachment } from '../../infrastructure/Response.mjs'
|
import { prepareZipAttachment } from '../../infrastructure/Response.mjs'
|
||||||
@@ -205,7 +208,8 @@ const _CompileController = {
|
|||||||
// Allow building a per-project Python venv from requirements.txt only for
|
// Allow building a per-project Python venv from requirements.txt only for
|
||||||
// the project owner and invited collaborators — never anonymous or
|
// the project owner and invited collaborators — never anonymous or
|
||||||
// link-sharing users.
|
// link-sharing users.
|
||||||
options.allowPythonInstall = await userCanInstallPython(userId, projectId)
|
const anonToken = TokenAccessHandler.getRequestToken(req, projectId)
|
||||||
|
options.allowPythonInstall = await userCanInstallPython(userId, projectId, anonToken)
|
||||||
|
|
||||||
let {
|
let {
|
||||||
enablePdfCaching,
|
enablePdfCaching,
|
||||||
@@ -300,6 +304,30 @@ const _CompileController = {
|
|||||||
? getOutputFilesArchiveSpecification(projectId, userId, buildId)
|
? getOutputFilesArchiveSpecification(projectId, userId, buildId)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
// Persist quarto output flavor so the project-list badge can distinguish
|
||||||
|
// RevealJS presentations from PDF documents without needing a compile.
|
||||||
|
// options.compiler is not sent by the frontend, so we read the stored
|
||||||
|
// compiler from the DB. Done fire-and-forget so it never delays the response.
|
||||||
|
if (status === 'success') {
|
||||||
|
const isHtml = outputFiles.some(f => f.path === 'output.html')
|
||||||
|
ProjectGetter.promises
|
||||||
|
.getProject(projectId, { compiler: 1 })
|
||||||
|
.then(project => {
|
||||||
|
if (project?.compiler !== 'quarto') return
|
||||||
|
return Project.updateOne(
|
||||||
|
{ _id: projectId },
|
||||||
|
{ quartoFlavor: isHtml ? 'revealjs' : 'pdf' }
|
||||||
|
).exec()
|
||||||
|
})
|
||||||
|
.catch(err =>
|
||||||
|
logger.warn({ err, projectId }, 'failed to update quartoFlavor')
|
||||||
|
)
|
||||||
|
|
||||||
|
ThumbnailManager.generateAndCacheThumbnail(projectId, userId, buildId).catch(
|
||||||
|
err => logger.warn({ err, projectId }, 'thumbnail cache error')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status,
|
status,
|
||||||
outputFiles,
|
outputFiles,
|
||||||
@@ -323,6 +351,15 @@ const _CompileController = {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getProjectThumbnail(req, res) {
|
||||||
|
const projectId = req.params.Project_id
|
||||||
|
const jpeg = await ThumbnailManager.getCachedThumbnail(projectId)
|
||||||
|
if (!jpeg) return res.sendStatus(404)
|
||||||
|
res.setHeader('Content-Type', 'image/jpeg')
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=60')
|
||||||
|
res.send(jpeg)
|
||||||
|
},
|
||||||
|
|
||||||
// Used for submissions through the public API
|
// Used for submissions through the public API
|
||||||
async compileSubmission(req, res) {
|
async compileSubmission(req, res) {
|
||||||
res.setTimeout(COMPILE_TIMEOUT_MS)
|
res.setTimeout(COMPILE_TIMEOUT_MS)
|
||||||
@@ -799,6 +836,7 @@ const CompileController = {
|
|||||||
proxySyncPdf: expressify(_CompileController.proxySyncPdf),
|
proxySyncPdf: expressify(_CompileController.proxySyncPdf),
|
||||||
proxySyncCode: expressify(_CompileController.proxySyncCode),
|
proxySyncCode: expressify(_CompileController.proxySyncCode),
|
||||||
wordCount: expressify(_CompileController.wordCount),
|
wordCount: expressify(_CompileController.wordCount),
|
||||||
|
getProjectThumbnail: expressify(_CompileController.getProjectThumbnail),
|
||||||
|
|
||||||
_getSafeProjectName: _CompileController._getSafeProjectName,
|
_getSafeProjectName: _CompileController._getSafeProjectName,
|
||||||
_getSplitTestOptions,
|
_getSplitTestOptions,
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import AuthorizationManager from '../Authorization/AuthorizationManager.mjs'
|
|||||||
|
|
||||||
// Whether this user may have the compiler install a project's requirements.txt
|
// Whether this user may have the compiler install a project's requirements.txt
|
||||||
// into a cached venv (so Quarto's Python cells can use libraries beyond the
|
// into a cached venv (so Quarto's Python cells can use libraries beyond the
|
||||||
// bundled base set). Gated to the project owner + invited collaborators (any
|
// bundled base set). Allowed for any user who can access the project — owner,
|
||||||
// role): ignorePublicAccess excludes link-sharing/public and anonymous users,
|
// invited collaborators, token-link users, and public-project readers — since
|
||||||
// who fall back to the base Python interpreter. Returns false when the feature
|
// the set of packages to install is already controlled by requirements.vrf
|
||||||
// is disabled or the privilege check fails.
|
// (writable only by project members with write access). Returns false when the
|
||||||
export async function userCanInstallPython(userId, projectId) {
|
// feature is disabled, the privilege check fails, or the user has no access.
|
||||||
|
export async function userCanInstallPython(userId, projectId, token = null) {
|
||||||
if (!Settings.enableProjectPythonVenv) {
|
if (!Settings.enableProjectPythonVenv) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -17,8 +18,7 @@ export async function userCanInstallPython(userId, projectId) {
|
|||||||
await AuthorizationManager.promises.getPrivilegeLevelForProject(
|
await AuthorizationManager.promises.getPrivilegeLevelForProject(
|
||||||
userId,
|
userId,
|
||||||
projectId,
|
projectId,
|
||||||
null,
|
token
|
||||||
{ ignorePublicAccess: true }
|
|
||||||
)
|
)
|
||||||
return Boolean(privilegeLevel)
|
return Boolean(privilegeLevel)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import RedisWrapper from '../../infrastructure/RedisWrapper.mjs'
|
||||||
|
import { fetchStreamWithResponse, RequestFailedError } from '@overleaf/fetch-utils'
|
||||||
|
|
||||||
|
const rclient = RedisWrapper.client('web')
|
||||||
|
const THUMBNAIL_TTL = 60 * 60 * 24 * 90 // 90 days
|
||||||
|
|
||||||
|
function redisKey(projectId) {
|
||||||
|
return `Thumbnail:${projectId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function clsiThumbnailUrl(projectId, userId, buildId) {
|
||||||
|
return `${Settings.apis.clsi.url}/project/${projectId}/user/${userId}/build/${buildId}/thumbnail`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateAndCacheThumbnail(projectId, userId, buildId) {
|
||||||
|
if (!userId || !buildId) return
|
||||||
|
|
||||||
|
const url = clsiThumbnailUrl(projectId, userId, buildId)
|
||||||
|
let stream
|
||||||
|
try {
|
||||||
|
;({ stream } = await fetchStreamWithResponse(url))
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof RequestFailedError)) {
|
||||||
|
logger.warn(
|
||||||
|
{ err, projectId, buildId },
|
||||||
|
'unexpected error fetching thumbnail from CLSI'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chunks = []
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
const jpegBuffer = Buffer.concat(chunks)
|
||||||
|
if (jpegBuffer.length === 0) return
|
||||||
|
await rclient.set(
|
||||||
|
redisKey(projectId),
|
||||||
|
jpegBuffer.toString('base64'),
|
||||||
|
'EX',
|
||||||
|
THUMBNAIL_TTL
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, projectId }, 'failed to cache thumbnail in Redis')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCachedThumbnail(projectId) {
|
||||||
|
try {
|
||||||
|
const b64 = await rclient.get(redisKey(projectId))
|
||||||
|
if (!b64) return null
|
||||||
|
return Buffer.from(b64, 'base64')
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, projectId }, 'failed to get thumbnail from Redis')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { generateAndCacheThumbnail, getCachedThumbnail }
|
||||||
@@ -13,6 +13,15 @@ import { expressify } from '@overleaf/promise-utils'
|
|||||||
import { pipeline } from 'node:stream/promises'
|
import { pipeline } from 'node:stream/promises'
|
||||||
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
|
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
|
||||||
import { DocumentConversionError } from '../Errors/Errors.js'
|
import { DocumentConversionError } from '../Errors/Errors.js'
|
||||||
|
import ProjectLocator from '../Project/ProjectLocator.mjs'
|
||||||
|
import ProjectEntityUpdateHandler from '../Project/ProjectEntityUpdateHandler.mjs'
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
|
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import nodePath from 'node:path'
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
const { z, zz, parseReq } = Validation
|
const { z, zz, parseReq } = Validation
|
||||||
|
|
||||||
@@ -20,6 +29,8 @@ const SUPPORTED_CONVERSION_TYPES = new Map([
|
|||||||
['docx', 'docx'],
|
['docx', 'docx'],
|
||||||
['markdown', 'zip'],
|
['markdown', 'zip'],
|
||||||
['html', 'zip'],
|
['html', 'zip'],
|
||||||
|
['typst', 'typ'],
|
||||||
|
['latex', 'tex'],
|
||||||
])
|
])
|
||||||
|
|
||||||
const exportProjectConversionSchema = z.object({
|
const exportProjectConversionSchema = z.object({
|
||||||
@@ -99,14 +110,14 @@ async function exportProjectConversion(req, res) {
|
|||||||
{ compileFromHistory, rootResourcePath }
|
{ compileFromHistory, rootResourcePath }
|
||||||
)
|
)
|
||||||
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', {
|
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', {
|
||||||
sourceFormat: 'latex',
|
sourceFormat: type === 'latex' ? 'typst' : 'latex',
|
||||||
targetFormat: type,
|
targetFormat: type,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
operation: 'export',
|
operation: 'export',
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', {
|
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', {
|
||||||
sourceFormat: 'latex',
|
sourceFormat: type === 'latex' ? 'typst' : 'latex',
|
||||||
targetFormat: type,
|
targetFormat: type,
|
||||||
status: 'failure',
|
status: 'failure',
|
||||||
operation: 'export',
|
operation: 'export',
|
||||||
@@ -164,9 +175,102 @@ async function downloadPreparedProjectExport(req, res) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DOC_CONVERSION_CONFIGS = {
|
||||||
|
typst: { fromExt: 'tex', toExt: 'typ', pandocFrom: 'latex', pandocTo: 'typst' },
|
||||||
|
latex: { fromExt: 'typ', toExt: 'tex', pandocFrom: 'typst', pandocTo: 'latex' },
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertDocInProject(req, res) {
|
||||||
|
const { Project_id: projectId, Doc_id: docId, type } = req.params
|
||||||
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
|
||||||
|
const config = DOC_CONVERSION_CONFIGS[type]
|
||||||
|
if (!config) return res.status(400).json({ error: 'unsupported conversion type' })
|
||||||
|
|
||||||
|
const { lines } = await DocumentUpdaterHandler.promises.getDocument(
|
||||||
|
projectId,
|
||||||
|
docId,
|
||||||
|
-1
|
||||||
|
)
|
||||||
|
const content = lines.join('\n')
|
||||||
|
|
||||||
|
const { element, folder } = await ProjectLocator.promises.findElement({
|
||||||
|
project_id: projectId,
|
||||||
|
element_id: docId,
|
||||||
|
type: 'doc',
|
||||||
|
})
|
||||||
|
const parentFolderId = folder._id
|
||||||
|
|
||||||
|
const baseName = element.name.endsWith('.' + config.fromExt)
|
||||||
|
? element.name.slice(0, -(config.fromExt.length + 1))
|
||||||
|
: element.name
|
||||||
|
const outputName = baseName + '.' + config.toExt
|
||||||
|
|
||||||
|
const tmpDir = await mkdtemp(nodePath.join(tmpdir(), 'verso-convert-'))
|
||||||
|
let convertedContent
|
||||||
|
try {
|
||||||
|
const inputPath = nodePath.join(tmpDir, `input.${config.fromExt}`)
|
||||||
|
const outputPath = nodePath.join(tmpDir, `output.${config.toExt}`)
|
||||||
|
await writeFile(inputPath, content, 'utf8')
|
||||||
|
try {
|
||||||
|
await execFileAsync(
|
||||||
|
'pandoc',
|
||||||
|
[
|
||||||
|
inputPath,
|
||||||
|
'--from', config.pandocFrom,
|
||||||
|
'--to', config.pandocTo,
|
||||||
|
'--output', outputPath,
|
||||||
|
'--standalone',
|
||||||
|
],
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(422).json({ error: err.stderr || err.message })
|
||||||
|
}
|
||||||
|
convertedContent = await readFile(outputPath, 'utf8')
|
||||||
|
} finally {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingDoc = folder.docs?.find(d => d.name === outputName)
|
||||||
|
let resultDocId, resultName
|
||||||
|
if (existingDoc) {
|
||||||
|
await DocumentUpdaterHandler.promises.setDocument(
|
||||||
|
projectId,
|
||||||
|
existingDoc._id.toString(),
|
||||||
|
userId,
|
||||||
|
convertedContent.split('\n'),
|
||||||
|
'convert'
|
||||||
|
)
|
||||||
|
resultDocId = existingDoc._id
|
||||||
|
resultName = existingDoc.name
|
||||||
|
} else {
|
||||||
|
const { doc } = await ProjectEntityUpdateHandler.promises.addDoc(
|
||||||
|
projectId,
|
||||||
|
parentFolderId,
|
||||||
|
outputName,
|
||||||
|
convertedContent.split('\n'),
|
||||||
|
userId,
|
||||||
|
'convert'
|
||||||
|
)
|
||||||
|
resultDocId = doc._id
|
||||||
|
resultName = doc.name
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectAuditLogHandler.addEntryInBackground(
|
||||||
|
projectId,
|
||||||
|
'doc-converted',
|
||||||
|
userId,
|
||||||
|
req.ip
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({ docId: resultDocId, name: resultName, parentFolderId: parentFolderId.toString(), isNew: !existingDoc })
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
exportProjectConversion: expressify(exportProjectConversion),
|
exportProjectConversion: expressify(exportProjectConversion),
|
||||||
downloadPreparedProjectExport: expressify(downloadPreparedProjectExport),
|
downloadPreparedProjectExport: expressify(downloadPreparedProjectExport),
|
||||||
|
convertDocInProject: expressify(convertDocInProject),
|
||||||
|
|
||||||
downloadProject(req, res, next) {
|
downloadProject(req, res, next) {
|
||||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default _.template(`\
|
|||||||
<tr style="padding: 0; text-align: left; vertical-align: top;">
|
<tr style="padding: 0; text-align: left; vertical-align: top;">
|
||||||
<th style="margin: 0; padding: 0; text-align: left;">
|
<th style="margin: 0; padding: 0; text-align: left;">
|
||||||
<% if (title) { %>
|
<% if (title) { %>
|
||||||
<h3 class="force-overleaf-style" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
|
<h3 class="force-verso-style" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
|
||||||
<%= title %>
|
<%= title %>
|
||||||
</h3>
|
</h3>
|
||||||
<% } %>
|
<% } %>
|
||||||
@@ -25,7 +25,7 @@ export default _.template(`\
|
|||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<% (message).forEach(function(paragraph) { %>
|
<% (message).forEach(function(paragraph) { %>
|
||||||
<p class="force-overleaf-style" style="margin: 0 0 10px 0; padding: 0;">
|
<p class="force-verso-style" style="margin: 0 0 10px 0; padding: 0;">
|
||||||
<%= paragraph %>
|
<%= paragraph %>
|
||||||
</p>
|
</p>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default _.template(`\
|
|||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<% (message).forEach(function(paragraph) { %>
|
<% (message).forEach(function(paragraph) { %>
|
||||||
<p class="force-overleaf-style" style="margin: 0 0 16px 0;">
|
<p class="force-verso-style" style="margin: 0 0 16px 0;">
|
||||||
<%= paragraph %>
|
<%= paragraph %>
|
||||||
</p>
|
</p>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default _.template(`\
|
|||||||
<tr style="padding: 0; text-align: left; vertical-align: top;">
|
<tr style="padding: 0; text-align: left; vertical-align: top;">
|
||||||
<th style="margin: 0; padding: 0; text-align: left;">
|
<th style="margin: 0; padding: 0; text-align: left;">
|
||||||
<% if (title) { %>
|
<% if (title) { %>
|
||||||
<h3 class="force-overleaf-style" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
|
<h3 class="force-verso-style" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
|
||||||
<%= title %>
|
<%= title %>
|
||||||
</h3>
|
</h3>
|
||||||
<% } %>
|
<% } %>
|
||||||
@@ -25,7 +25,7 @@ export default _.template(`\
|
|||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<% (message).forEach(function(paragraph) { %>
|
<% (message).forEach(function(paragraph) { %>
|
||||||
<p class="force-overleaf-style" style="margin: 0 0 10px 0; padding: 0;">
|
<p class="force-verso-style" style="margin: 0 0 10px 0; padding: 0;">
|
||||||
<%= paragraph %>
|
<%= paragraph %>
|
||||||
</p>
|
</p>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
@@ -52,7 +52,7 @@ export default _.template(`\
|
|||||||
<p style="margin: 0; padding: 0;"> </p>
|
<p style="margin: 0; padding: 0;"> </p>
|
||||||
|
|
||||||
<% (secondaryMessage).forEach(function(paragraph) { %>
|
<% (secondaryMessage).forEach(function(paragraph) { %>
|
||||||
<p class="force-overleaf-style">
|
<p class="force-verso-style">
|
||||||
<%= paragraph %>
|
<%= paragraph %>
|
||||||
</p>
|
</p>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
@@ -60,11 +60,11 @@ export default _.template(`\
|
|||||||
|
|
||||||
<p style="margin: 0; padding: 0;"> </p>
|
<p style="margin: 0; padding: 0;"> </p>
|
||||||
|
|
||||||
<p class="force-overleaf-style" style="font-size: 12px;">
|
<p class="force-verso-style" style="font-size: 12px;">
|
||||||
If the button above does not appear, please copy and paste this link into your browser's address bar:
|
If the button above does not appear, please copy and paste this link into your browser's address bar:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="force-overleaf-style" style="font-size: 12px;">
|
<p class="force-verso-style" style="font-size: 12px;">
|
||||||
<%= ctaURL %>
|
<%= ctaURL %>
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default _.template(`\
|
|||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<% (message).forEach(function(paragraph) { %>
|
<% (message).forEach(function(paragraph) { %>
|
||||||
<p class="force-overleaf-style" style="margin: 0 0 16px 0;">
|
<p class="force-verso-style" style="margin: 0 0 16px 0;">
|
||||||
<%= paragraph %>
|
<%= paragraph %>
|
||||||
</p>
|
</p>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
@@ -32,7 +32,7 @@ export default _.template(`\
|
|||||||
|
|
||||||
<% if (secondaryMessage && secondaryMessage.length > 0) { %>
|
<% if (secondaryMessage && secondaryMessage.length > 0) { %>
|
||||||
<% (secondaryMessage).forEach(function(paragraph) { %>
|
<% (secondaryMessage).forEach(function(paragraph) { %>
|
||||||
<p class="force-overleaf-style" style="margin: 0 0 16px 0;">
|
<p class="force-verso-style" style="margin: 0 0 16px 0;">
|
||||||
<%= paragraph %>
|
<%= paragraph %>
|
||||||
</p>
|
</p>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ templates.confirmCode = NoCTAEmailTemplate({
|
|||||||
return ''
|
return ''
|
||||||
},
|
},
|
||||||
subject(opts) {
|
subject(opts) {
|
||||||
return `Confirm your email address on Overleaf (${opts.confirmCode})`
|
return `Confirm your email address on ${settings.appName} (${opts.confirmCode})`
|
||||||
},
|
},
|
||||||
title(opts) {
|
title(opts) {
|
||||||
return 'Confirm your email address'
|
return 'Confirm your email address'
|
||||||
@@ -284,7 +284,7 @@ templates.confirmCode = NoCTAEmailTemplate({
|
|||||||
message(opts, isPlainText) {
|
message(opts, isPlainText) {
|
||||||
const msg = opts.welcomeUser
|
const msg = opts.welcomeUser
|
||||||
? [
|
? [
|
||||||
`Welcome to Overleaf! We're so glad you joined us.`,
|
`Welcome to ${settings.appName}! We're so glad you joined us.`,
|
||||||
'Use this 6-digit confirmation code to finish your setup.',
|
'Use this 6-digit confirmation code to finish your setup.',
|
||||||
]
|
]
|
||||||
: ['Use this 6-digit code to confirm your email address.']
|
: ['Use this 6-digit code to confirm your email address.']
|
||||||
@@ -477,7 +477,7 @@ templates.inviteNewUserToJoinManagedUsers = ctaTemplate({
|
|||||||
templates.groupSSOLinkingInvite = ctaTemplate({
|
templates.groupSSOLinkingInvite = ctaTemplate({
|
||||||
subject(opts) {
|
subject(opts) {
|
||||||
const subjectPrefix = opts.reminder ? 'Reminder: ' : 'Action required: '
|
const subjectPrefix = opts.reminder ? 'Reminder: ' : 'Action required: '
|
||||||
return `${subjectPrefix}Authenticate your Overleaf account`
|
return `${subjectPrefix}Authenticate your ${settings.appName} account`
|
||||||
},
|
},
|
||||||
title(opts) {
|
title(opts) {
|
||||||
const titlePrefix = opts.reminder ? 'Reminder: ' : ''
|
const titlePrefix = opts.reminder ? 'Reminder: ' : ''
|
||||||
@@ -495,8 +495,8 @@ templates.groupSSOLinkingInvite = ctaTemplate({
|
|||||||
</div>
|
</div>
|
||||||
</br>
|
</br>
|
||||||
<div>
|
<div>
|
||||||
You won't need to remember a separate email address and password to sign in to Overleaf.
|
You won't need to remember a separate email address and password to sign in to ${settings.appName}.
|
||||||
All you need to do is authenticate your existing Overleaf account with your SSO provider.
|
All you need to do is authenticate your existing ${settings.appName} account with your SSO provider.
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
]
|
]
|
||||||
@@ -517,7 +517,7 @@ templates.groupSSOLinkingInvite = ctaTemplate({
|
|||||||
|
|
||||||
templates.groupSSOReauthenticate = ctaTemplate({
|
templates.groupSSOReauthenticate = ctaTemplate({
|
||||||
subject(opts) {
|
subject(opts) {
|
||||||
return 'Action required: Reauthenticate your Overleaf account'
|
return `Action required: Reauthenticate your ${settings.appName} account`
|
||||||
},
|
},
|
||||||
title(opts) {
|
title(opts) {
|
||||||
return 'Action required: Reauthenticate SSO'
|
return 'Action required: Reauthenticate SSO'
|
||||||
@@ -526,8 +526,8 @@ templates.groupSSOReauthenticate = ctaTemplate({
|
|||||||
return [
|
return [
|
||||||
`Hi,
|
`Hi,
|
||||||
<div>
|
<div>
|
||||||
Single sign-on for your Overleaf group has been updated.
|
Single sign-on for your ${settings.appName} group has been updated.
|
||||||
This means you need to reauthenticate your Overleaf account with your group’s SSO provider.
|
This means you need to reauthenticate your ${settings.appName} account with your group’s SSO provider.
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
]
|
]
|
||||||
@@ -538,7 +538,7 @@ templates.groupSSOReauthenticate = ctaTemplate({
|
|||||||
} else {
|
} else {
|
||||||
const passwordResetUrl = `${settings.siteUrl}/user/password/reset`
|
const passwordResetUrl = `${settings.siteUrl}/user/password/reset`
|
||||||
return [
|
return [
|
||||||
`If you’re not currently logged in to Overleaf, you'll need to <a href="${passwordResetUrl}">set a new password</a> to reauthenticate.`,
|
`If you’re not currently logged in to ${settings.appName}, you’ll need to <a href="${passwordResetUrl}">set a new password</a> to reauthenticate.`,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -556,9 +556,9 @@ templates.groupSSOReauthenticate = ctaTemplate({
|
|||||||
templates.groupSSODisabled = ctaTemplate({
|
templates.groupSSODisabled = ctaTemplate({
|
||||||
subject(opts) {
|
subject(opts) {
|
||||||
if (opts.userIsManaged) {
|
if (opts.userIsManaged) {
|
||||||
return `Action required: Set your Overleaf password`
|
return `Action required: Set your ${settings.appName} password`
|
||||||
} else {
|
} else {
|
||||||
return 'A change to your Overleaf login options'
|
return `A change to your ${settings.appName} login options`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
title(opts) {
|
title(opts) {
|
||||||
@@ -567,12 +567,12 @@ templates.groupSSODisabled = ctaTemplate({
|
|||||||
message(opts, isPlainText) {
|
message(opts, isPlainText) {
|
||||||
const loginUrl = `${settings.siteUrl}/login`
|
const loginUrl = `${settings.siteUrl}/login`
|
||||||
let whatDoesThisMeanExplanation = [
|
let whatDoesThisMeanExplanation = [
|
||||||
`You can still log in to Overleaf using one of our other <a href="${loginUrl}" style="color: #0F7A06; text-decoration: none;">login options</a> or with your email address and password.`,
|
`You can still log in to ${settings.appName} using one of our other <a href="${loginUrl}" style="color: #1d7a6e; text-decoration: none;">login options</a> or with your email address and password.`,
|
||||||
`If you don't have a password, you can set one now.`,
|
`If you don't have a password, you can set one now.`,
|
||||||
]
|
]
|
||||||
if (opts.userIsManaged) {
|
if (opts.userIsManaged) {
|
||||||
whatDoesThisMeanExplanation = [
|
whatDoesThisMeanExplanation = [
|
||||||
'You now need an email address and password to sign in to your Overleaf account.',
|
`You now need an email address and password to sign in to your ${settings.appName} account.`,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,7 +626,7 @@ templates.surrenderAccountForManagedUsers = ctaTemplate({
|
|||||||
|
|
||||||
const managedUsersLink = EmailMessageHelper.displayLink(
|
const managedUsersLink = EmailMessageHelper.displayLink(
|
||||||
'user account management',
|
'user account management',
|
||||||
`${settings.siteUrl}/learn/how-to/Understanding_Managed_Overleaf_Accounts`,
|
`${settings.siteUrl}/learn/how-to/Understanding_Managed_Accounts`,
|
||||||
isPlainText
|
isPlainText
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -757,22 +757,17 @@ templates.userOnboardingEmail = NoCTAEmailTemplate({
|
|||||||
message(opts, isPlainText) {
|
message(opts, isPlainText) {
|
||||||
const learnLatexLink = EmailMessageHelper.displayLink(
|
const learnLatexLink = EmailMessageHelper.displayLink(
|
||||||
'Learn LaTeX in 30 minutes',
|
'Learn LaTeX in 30 minutes',
|
||||||
`${settings.siteUrl}/learn/latex/Learn_LaTeX_in_30_minutes?utm_source=overleaf&utm_medium=email&utm_campaign=onboarding`,
|
`${settings.siteUrl}/learn/latex/Learn_LaTeX_in_30_minutes?utm_source=verso&utm_medium=email&utm_campaign=onboarding`,
|
||||||
isPlainText
|
isPlainText
|
||||||
)
|
)
|
||||||
const templatesLinks = EmailMessageHelper.displayLink(
|
const templatesLinks = EmailMessageHelper.displayLink(
|
||||||
'Find a beautiful template',
|
'Find a beautiful template',
|
||||||
`${settings.siteUrl}/latex/templates?utm_source=overleaf&utm_medium=email&utm_campaign=onboarding`,
|
`${settings.siteUrl}/latex/templates?utm_source=verso&utm_medium=email&utm_campaign=onboarding`,
|
||||||
isPlainText
|
isPlainText
|
||||||
)
|
)
|
||||||
const collaboratorsLink = EmailMessageHelper.displayLink(
|
const collaboratorsLink = EmailMessageHelper.displayLink(
|
||||||
'Work with your collaborators',
|
'Work with your collaborators',
|
||||||
`${settings.siteUrl}/learn/how-to/Sharing_a_project?utm_source=overleaf&utm_medium=email&utm_campaign=onboarding`,
|
`${settings.siteUrl}/learn/how-to/Sharing_a_project?utm_source=verso&utm_medium=email&utm_campaign=onboarding`,
|
||||||
isPlainText
|
|
||||||
)
|
|
||||||
const siteLink = EmailMessageHelper.displayLink(
|
|
||||||
'www.overleaf.com',
|
|
||||||
settings.siteUrl,
|
|
||||||
isPlainText
|
isPlainText
|
||||||
)
|
)
|
||||||
const userSettingsLink = EmailMessageHelper.displayLink(
|
const userSettingsLink = EmailMessageHelper.displayLink(
|
||||||
@@ -780,28 +775,21 @@ templates.userOnboardingEmail = NoCTAEmailTemplate({
|
|||||||
`${settings.siteUrl}/user/email-preferences`,
|
`${settings.siteUrl}/user/email-preferences`,
|
||||||
isPlainText
|
isPlainText
|
||||||
)
|
)
|
||||||
const onboardingSurveyLink = EmailMessageHelper.displayLink(
|
|
||||||
'Join our user feedback program',
|
|
||||||
'https://forms.gle/DB7pdk2B1VFQqVVB9',
|
|
||||||
isPlainText
|
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
`Thanks for signing up for ${settings.appName} recently. We hope you've been finding it useful! Here are some key features to help you get the most out of the service:`,
|
`Thanks for signing up for ${settings.appName} recently. We hope you've been finding it useful! Here are some key features to help you get the most out of the service:`,
|
||||||
`${learnLatexLink}: In this tutorial we provide a quick and easy first introduction to LaTeX with no prior knowledge required. By the time you are finished, you will have written your first LaTeX document!`,
|
`${learnLatexLink}: In this tutorial we provide a quick and easy first introduction to LaTeX with no prior knowledge required. By the time you are finished, you will have written your first LaTeX document!`,
|
||||||
`${templatesLinks}: If you're looking for a template or example to get started, we've a large selection available in our template gallery, including CVs, project reports, journal articles and more.`,
|
`${templatesLinks}: If you're looking for a template or example to get started, we've a large selection available in our template gallery, including CVs, project reports, journal articles and more.`,
|
||||||
`${collaboratorsLink}: One of the key features of Overleaf is the ability to share projects and collaborate on them with other users. Find out how to share your projects with your colleagues in this quick how-to guide.`,
|
`${collaboratorsLink}: One of the key features of ${settings.appName} is the ability to share projects and collaborate on them with other users. Find out how to share your projects with your colleagues in this quick how-to guide.`,
|
||||||
`${onboardingSurveyLink} to help us make Overleaf even better!`,
|
`Thanks for using ${settings.appName}!`,
|
||||||
'Thanks again for using Overleaf :)',
|
`The ${settings.appName} Team<hr>`,
|
||||||
`Lee`,
|
`You're receiving this email because you've recently signed up for a ${settings.appName} account. If you've previously subscribed to emails about product offers and company news and events, you can unsubscribe ${userSettingsLink}.`,
|
||||||
`Lee Shalit<br />CEO<br />${siteLink}<hr>`,
|
|
||||||
`You're receiving this email because you've recently signed up for an Overleaf account. If you've previously subscribed to emails about product offers and company news and events, you can unsubscribe ${userSettingsLink}.`,
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
templates.securityAlert = NoCTAEmailTemplate({
|
templates.securityAlert = NoCTAEmailTemplate({
|
||||||
subject(opts) {
|
subject(opts) {
|
||||||
return `Overleaf security note: ${opts.action}`
|
return `${settings.appName} security note: ${opts.action}`
|
||||||
},
|
},
|
||||||
title(opts) {
|
title(opts) {
|
||||||
return opts.action.charAt(0).toUpperCase() + opts.action.slice(1)
|
return opts.action.charAt(0).toUpperCase() + opts.action.slice(1)
|
||||||
@@ -837,12 +825,11 @@ templates.securityAlert = NoCTAEmailTemplate({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const GIT_TOKEN_DOCS_URL =
|
const GIT_TOKEN_DOCS_URL = `${settings.siteUrl}/learn/how-to/Git_integration`
|
||||||
'https://docs.overleaf.com/integrations-and-add-ons/git-integration-and-github-synchronization/git-integration/git-integration-authentication-tokens#how-to-generate-authentication-tokens'
|
|
||||||
|
|
||||||
templates.gitTokenExpiringSoon = NoCTAEmailTemplate({
|
templates.gitTokenExpiringSoon = NoCTAEmailTemplate({
|
||||||
subject() {
|
subject() {
|
||||||
return 'Your Overleaf token is about to expire'
|
return `Your ${settings.appName} token is about to expire`
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return 'Your token is about to expire'
|
return 'Your token is about to expire'
|
||||||
@@ -866,14 +853,14 @@ templates.gitTokenExpiringSoon = NoCTAEmailTemplate({
|
|||||||
`If you haven't already, you'll need to generate a new token in your ${settingsLink}.`,
|
`If you haven't already, you'll need to generate a new token in your ${settingsLink}.`,
|
||||||
`Take a look at ${docsLink} if you need more help.`,
|
`Take a look at ${docsLink} if you need more help.`,
|
||||||
'All the best,',
|
'All the best,',
|
||||||
'Team Overleaf',
|
`The ${settings.appName} Team`,
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
templates.gitTokenExpired = NoCTAEmailTemplate({
|
templates.gitTokenExpired = NoCTAEmailTemplate({
|
||||||
subject() {
|
subject() {
|
||||||
return 'Your Overleaf token has expired'
|
return `Your ${settings.appName} token has expired`
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return 'Token expired'
|
return 'Token expired'
|
||||||
@@ -897,7 +884,7 @@ templates.gitTokenExpired = NoCTAEmailTemplate({
|
|||||||
`If you haven't already, you'll need to generate a new token in your ${settingsLink}.`,
|
`If you haven't already, you'll need to generate a new token in your ${settingsLink}.`,
|
||||||
`Take a look at ${docsLink} if you need more help.`,
|
`Take a look at ${docsLink} if you need more help.`,
|
||||||
'All the best,',
|
'All the best,',
|
||||||
'Team Overleaf',
|
`The ${settings.appName} Team`,
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1040,7 +1027,7 @@ templates.removeGroupMember = NoCTAEmailTemplate({
|
|||||||
|
|
||||||
templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
|
templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
|
||||||
subject(opts) {
|
subject(opts) {
|
||||||
return `Action required: Tax exemption verification for Overleaf [${opts.ein}]`
|
return `Action required: Tax exemption verification for ${settings.appName} [${opts.ein}]`
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return 'Action required: Tax exemption verification'
|
return 'Action required: Tax exemption verification'
|
||||||
@@ -1060,7 +1047,7 @@ templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
|
|||||||
'If you have any questions, let us know by replying to this email.',
|
'If you have any questions, let us know by replying to this email.',
|
||||||
'<br/>',
|
'<br/>',
|
||||||
'Best wishes,',
|
'Best wishes,',
|
||||||
'Team Overleaf',
|
`The ${settings.appName} Team`,
|
||||||
'<br/>',
|
'<br/>',
|
||||||
`Our reference: ${opts.stripeCustomerId}`,
|
`Our reference: ${opts.stripeCustomerId}`,
|
||||||
]
|
]
|
||||||
@@ -1069,17 +1056,17 @@ templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
|
|||||||
|
|
||||||
templates.groupMemberLimitWarning = ctaTemplate({
|
templates.groupMemberLimitWarning = ctaTemplate({
|
||||||
subject(opts) {
|
subject(opts) {
|
||||||
return `Action needed: Your Overleaf group is nearly out of licenses`
|
return `Action needed: Your ${settings.appName} group is nearly out of licenses`
|
||||||
},
|
},
|
||||||
title(opts) {
|
title(opts) {
|
||||||
return `Action needed: Your Overleaf group is nearly out of licenses`
|
return `Action needed: Your ${settings.appName} group is nearly out of licenses`
|
||||||
},
|
},
|
||||||
greeting(opts) {
|
greeting(opts) {
|
||||||
return opts.firstName ? `Hi ${opts.firstName},` : 'Hi there,'
|
return opts.firstName ? `Hi ${opts.firstName},` : 'Hi there,'
|
||||||
},
|
},
|
||||||
message(opts) {
|
message(opts) {
|
||||||
return [
|
return [
|
||||||
`Your Overleaf group <b>${opts.groupName}</b> is close to its license limit.`,
|
`Your ${settings.appName} group <b>${opts.groupName}</b> is close to its license limit.`,
|
||||||
`<b>${opts.currentMembers} of ${opts.membersLimit} licenses are in use (${opts.remainingSeats} remaining).</b>`,
|
`<b>${opts.currentMembers} of ${opts.membersLimit} licenses are in use (${opts.remainingSeats} remaining).</b>`,
|
||||||
'Because domain capture is enabled, users from your domain can join automatically via SSO.' +
|
'Because domain capture is enabled, users from your domain can join automatically via SSO.' +
|
||||||
'<br/>' +
|
'<br/>' +
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ export default _.template(`\
|
|||||||
.email-layout-table { border-collapse: collapse !important; }
|
.email-layout-table { border-collapse: collapse !important; }
|
||||||
a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; }
|
a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; }
|
||||||
|
|
||||||
.force-overleaf-style a,
|
.force-verso-style a,
|
||||||
.force-overleaf-style a[href] {
|
.force-verso-style a[href] {
|
||||||
color: ${colors.linkGreen} !important;
|
color: ${colors.linkGreen} !important;
|
||||||
text-decoration: underline !important;
|
text-decoration: underline !important;
|
||||||
-moz-hyphens: none;
|
-moz-hyphens: none;
|
||||||
@@ -69,10 +69,10 @@ export default _.template(`\
|
|||||||
-webkit-hyphens: none;
|
-webkit-hyphens: none;
|
||||||
hyphens: none;
|
hyphens: none;
|
||||||
}
|
}
|
||||||
.force-overleaf-style a:visited,
|
.force-verso-style a:visited,
|
||||||
.force-overleaf-style a[href]:visited { color: ${colors.linkGreen}; }
|
.force-verso-style a[href]:visited { color: ${colors.linkGreen}; }
|
||||||
.force-overleaf-style a:hover,
|
.force-verso-style a:hover,
|
||||||
.force-overleaf-style a[href]:hover { color: ${colors.linkHover}; }
|
.force-verso-style a[href]:hover { color: ${colors.linkHover}; }
|
||||||
|
|
||||||
@media only screen and (min-width: 621px) {
|
@media only screen and (min-width: 621px) {
|
||||||
.email-card-inner { padding: 56px !important; }
|
.email-card-inner { padding: 56px !important; }
|
||||||
@@ -149,7 +149,7 @@ export default _.template(`\
|
|||||||
|
|
||||||
<% if (footerMessage) { %>
|
<% if (footerMessage) { %>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" class="force-overleaf-style" style="padding: 0 0 32px 0; font-family: ${fontFamily}; font-size: 14px; color: ${colors.textMuted}; line-height: 20px;">
|
<td align="center" class="force-verso-style" style="padding: 0 0 32px 0; font-family: ${fontFamily}; font-size: 14px; color: ${colors.textMuted}; line-height: 20px;">
|
||||||
<%= footerMessage %>
|
<%= footerMessage %>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ export const colors = {
|
|||||||
textDark: '#1b222c',
|
textDark: '#1b222c',
|
||||||
textMuted: '#495365',
|
textMuted: '#495365',
|
||||||
background: '#f4f5f6',
|
background: '#f4f5f6',
|
||||||
ctaGreen: '#098842',
|
ctaGreen: '#2a9d8f',
|
||||||
ctaText: '#ffffff',
|
ctaText: '#ffffff',
|
||||||
linkGreen: '#1e6b41',
|
linkGreen: '#1d7a6e',
|
||||||
linkHover: '#155a30',
|
linkHover: '#155e55',
|
||||||
logoGreen: '#04652f',
|
logoGreen: '#2a9d8f',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1364,6 +1364,7 @@ const _ProjectController = {
|
|||||||
function getInitialLoadingScreenTheme(overallThemeSetting) {
|
function getInitialLoadingScreenTheme(overallThemeSetting) {
|
||||||
switch (overallThemeSetting) {
|
switch (overallThemeSetting) {
|
||||||
case 'light-':
|
case 'light-':
|
||||||
|
case 'lumiere-':
|
||||||
return 'light'
|
return 'light'
|
||||||
case '':
|
case '':
|
||||||
return 'dark'
|
return 'dark'
|
||||||
|
|||||||
@@ -681,7 +681,7 @@ async function _getProjects(
|
|||||||
const results = await Promise.all([
|
const results = await Promise.all([
|
||||||
ProjectGetter.promises.findAllUsersProjects(
|
ProjectGetter.promises.findAllUsersProjects(
|
||||||
userId,
|
userId,
|
||||||
'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens compiler'
|
'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens compiler quartoFlavor'
|
||||||
),
|
),
|
||||||
TagsHandler.promises.getAllTags(userId),
|
TagsHandler.promises.getAllTags(userId),
|
||||||
])
|
])
|
||||||
@@ -826,6 +826,7 @@ function _formatProjectInfo(project, accessLevel, source, userId) {
|
|||||||
archived,
|
archived,
|
||||||
trashed,
|
trashed,
|
||||||
compiler: project.compiler,
|
compiler: project.compiler,
|
||||||
|
quartoFlavor: project.quartoFlavor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,6 +881,7 @@ async function _injectProjectUsers(projects) {
|
|||||||
: users[project.owner_ref.toString()],
|
: users[project.owner_ref.toString()],
|
||||||
owner_ref: undefined,
|
owner_ref: undefined,
|
||||||
compiler: project.compiler,
|
compiler: project.compiler,
|
||||||
|
quartoFlavor: project.quartoFlavor,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,24 +51,20 @@ async function setRootDocAutomatically(projectId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function findRootDocFileFromDirectory(directoryPath) {
|
async function findRootDocFileFromDirectory(directoryPath) {
|
||||||
const unsortedFiles = await globby(['**/*.{tex,Rtex,Rnw}'], {
|
// First try LaTeX files (look for \documentclass)
|
||||||
|
const unsortedTexFiles = await globby(['**/*.{tex,Rtex,Rnw,ltx}'], {
|
||||||
cwd: directoryPath,
|
cwd: directoryPath,
|
||||||
followSymlinkedDirectories: false,
|
followSymlinkedDirectories: false,
|
||||||
onlyFiles: true,
|
onlyFiles: true,
|
||||||
case: false,
|
case: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// the search order is such that we prefer files closer to the project root, then
|
const texFiles = await _sortFileList(unsortedTexFiles, directoryPath)
|
||||||
// we go by file size in ascending order, because people often have a main
|
let firstTexInRootFolder
|
||||||
// file that just includes a bunch of other files; then we go by name, in
|
|
||||||
// order to be deterministic
|
|
||||||
|
|
||||||
const files = await _sortFileList(unsortedFiles, directoryPath)
|
|
||||||
let firstFileInRootFolder
|
|
||||||
let doc = null
|
let doc = null
|
||||||
|
|
||||||
while (files.length > 0 && doc == null) {
|
while (texFiles.length > 0 && doc == null) {
|
||||||
const file = files.shift()
|
const file = texFiles.shift()
|
||||||
const content = await fs.promises.readFile(
|
const content = await fs.promises.readFile(
|
||||||
Path.join(directoryPath, file),
|
Path.join(directoryPath, file),
|
||||||
'utf8'
|
'utf8'
|
||||||
@@ -77,18 +73,52 @@ async function findRootDocFileFromDirectory(directoryPath) {
|
|||||||
if (DocumentHelper.contentHasDocumentclass(normalizedContent)) {
|
if (DocumentHelper.contentHasDocumentclass(normalizedContent)) {
|
||||||
doc = { path: file, content: normalizedContent }
|
doc = { path: file, content: normalizedContent }
|
||||||
}
|
}
|
||||||
|
if (!firstTexInRootFolder && !file.includes('/')) {
|
||||||
if (!firstFileInRootFolder && !file.includes('/')) {
|
firstTexInRootFolder = { path: file, content: normalizedContent }
|
||||||
firstFileInRootFolder = { path: file, content: normalizedContent }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if no doc was found, use the first file in the root folder as the main doc
|
if (!doc && firstTexInRootFolder) {
|
||||||
if (!doc && firstFileInRootFolder) {
|
doc = firstTexInRootFolder
|
||||||
doc = firstFileInRootFolder
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { path: doc?.path, content: doc?.content }
|
if (doc) return { path: doc.path, content: doc.content }
|
||||||
|
|
||||||
|
// Then try Typst files
|
||||||
|
const typFiles = await globby(['*.typ'], {
|
||||||
|
cwd: directoryPath,
|
||||||
|
followSymlinkedDirectories: false,
|
||||||
|
onlyFiles: true,
|
||||||
|
case: false,
|
||||||
|
})
|
||||||
|
if (typFiles.length > 0) {
|
||||||
|
typFiles.sort()
|
||||||
|
const file = typFiles[0]
|
||||||
|
const content = await fs.promises.readFile(
|
||||||
|
Path.join(directoryPath, file),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
return { path: file, content }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then try Quarto/R Markdown files
|
||||||
|
const qmdFiles = await globby(['*.{qmd,Rmd,rmd}'], {
|
||||||
|
cwd: directoryPath,
|
||||||
|
followSymlinkedDirectories: false,
|
||||||
|
onlyFiles: true,
|
||||||
|
case: false,
|
||||||
|
})
|
||||||
|
if (qmdFiles.length > 0) {
|
||||||
|
qmdFiles.sort()
|
||||||
|
const file = qmdFiles[0]
|
||||||
|
const content = await fs.promises.readFile(
|
||||||
|
Path.join(directoryPath, file),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
return { path: file, content }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path: undefined, content: undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setRootDocFromName(projectId, rootDocName) {
|
async function setRootDocFromName(projectId, rootDocName) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const SYSTEM_THEME_USER_CUTOFF_DATE = new Date(Date.UTC(2026, 2, 2, 12, 0, 0)) // 12pm GMT on March 2, 2026
|
const SYSTEM_THEME_USER_CUTOFF_DATE = new Date(Date.UTC(2026, 2, 2, 12, 0, 0)) // 12pm GMT on March 2, 2026
|
||||||
|
const LUMIERE_THEME_USER_CUTOFF_DATE = new Date(Date.UTC(2026, 5, 11, 12, 0, 0)) // 12pm GMT on June 11, 2026
|
||||||
|
|
||||||
function getOverallTheme(user) {
|
function getOverallTheme(user) {
|
||||||
if (user.ace.overallTheme != null) {
|
if (user.ace.overallTheme != null) {
|
||||||
@@ -10,7 +11,11 @@ function getOverallTheme(user) {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'system'
|
if (user.signUpDate < LUMIERE_THEME_USER_CUTOFF_DATE) {
|
||||||
|
return 'system'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'lumiere-'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildUserSettings(_req, _res, user) {
|
async function buildUserSettings(_req, _res, user) {
|
||||||
@@ -41,4 +46,5 @@ async function buildUserSettings(_req, _res, user) {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
buildUserSettings,
|
buildUserSettings,
|
||||||
|
getOverallTheme,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
|
|||||||
|
|
||||||
const defaultsDeep = lodash.defaultsDeep
|
const defaultsDeep = lodash.defaultsDeep
|
||||||
|
|
||||||
|
// Send a JSON response compatible with both normal mode and streaming mode
|
||||||
|
// (where startStreamingResponse already sent HTTP 200 + chunked headers).
|
||||||
|
function sendUploadResponse(res, statusCode, body) {
|
||||||
|
if (res.headersSent) {
|
||||||
|
return res.end(JSON.stringify(body))
|
||||||
|
}
|
||||||
|
return res.status(statusCode).json(body)
|
||||||
|
}
|
||||||
|
|
||||||
const upload = multer(
|
const upload = multer(
|
||||||
defaultsDeep(
|
defaultsDeep(
|
||||||
{
|
{
|
||||||
@@ -92,7 +101,7 @@ async function uploadFile(req, res, next) {
|
|||||||
await fsPromises.unlink(path).catch(unlinkErr => {
|
await fsPromises.unlink(path).catch(unlinkErr => {
|
||||||
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
|
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
|
||||||
})
|
})
|
||||||
return res.status(422).json({
|
return sendUploadResponse(res, 422, {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'invalid_filename',
|
error: 'invalid_filename',
|
||||||
})
|
})
|
||||||
@@ -119,7 +128,7 @@ async function uploadFile(req, res, next) {
|
|||||||
await fsPromises.unlink(path).catch(unlinkErr => {
|
await fsPromises.unlink(path).catch(unlinkErr => {
|
||||||
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
|
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
|
||||||
})
|
})
|
||||||
throw error
|
return sendUploadResponse(res, 500, { success: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
return FileSystemImportManager.addEntity(
|
return FileSystemImportManager.addEntity(
|
||||||
@@ -134,22 +143,22 @@ async function uploadFile(req, res, next) {
|
|||||||
timer.done()
|
timer.done()
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
if (error.name === 'InvalidNameError') {
|
if (error.name === 'InvalidNameError') {
|
||||||
return res.status(422).json({
|
return sendUploadResponse(res, 422, {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'invalid_filename',
|
error: 'invalid_filename',
|
||||||
})
|
})
|
||||||
} else if (error instanceof DuplicateNameError) {
|
} else if (error instanceof DuplicateNameError) {
|
||||||
return res.status(422).json({
|
return sendUploadResponse(res, 422, {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'duplicate_file_name',
|
error: 'duplicate_file_name',
|
||||||
})
|
})
|
||||||
} else if (error.message === 'project_has_too_many_files') {
|
} else if (error.message === 'project_has_too_many_files') {
|
||||||
return res.status(422).json({
|
return sendUploadResponse(res, 422, {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'project_has_too_many_files',
|
error: 'project_has_too_many_files',
|
||||||
})
|
})
|
||||||
} else if (error.message === 'folder_not_found') {
|
} else if (error.message === 'folder_not_found') {
|
||||||
return res.status(422).json({
|
return sendUploadResponse(res, 422, {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'folder_not_found',
|
error: 'folder_not_found',
|
||||||
})
|
})
|
||||||
@@ -164,10 +173,10 @@ async function uploadFile(req, res, next) {
|
|||||||
},
|
},
|
||||||
'error uploading file'
|
'error uploading file'
|
||||||
)
|
)
|
||||||
return res.status(422).json({ success: false })
|
return sendUploadResponse(res, 422, { success: false })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return res.json({
|
return sendUploadResponse(res, 200, {
|
||||||
success: true,
|
success: true,
|
||||||
entity_id: entity?._id,
|
entity_id: entity?._id,
|
||||||
entity_type: entity?.type,
|
entity_type: entity?.type,
|
||||||
@@ -187,7 +196,7 @@ async function importDocument(req, res, next) {
|
|||||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
const { path } = req.file
|
const { path } = req.file
|
||||||
const conversionType = req.query.type
|
const conversionType = req.query.type
|
||||||
if (!['docx', 'markdown'].includes(conversionType)) {
|
if (!['docx', 'markdown', 'typst'].includes(conversionType)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: req.i18n.translate('invalid_import_type'),
|
error: req.i18n.translate('invalid_import_type'),
|
||||||
@@ -273,25 +282,28 @@ async function importDocument(req, res, next) {
|
|||||||
*/
|
*/
|
||||||
function multerMiddleware(req, res, next) {
|
function multerMiddleware(req, res, next) {
|
||||||
if (upload == null) {
|
if (upload == null) {
|
||||||
return res
|
return sendUploadResponse(res, 500, {
|
||||||
.status(500)
|
success: false,
|
||||||
.json({ success: false, error: req.i18n.translate('upload_failed') })
|
error: req.i18n.translate('upload_failed'),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return upload.single('qqfile')(
|
return upload.single('qqfile')(
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
/** @param {any} err */ function (err) {
|
/** @param {any} err */ function (err) {
|
||||||
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
|
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
|
||||||
return res
|
return sendUploadResponse(res, 422, {
|
||||||
.status(422)
|
success: false,
|
||||||
.json({ success: false, error: req.i18n.translate('file_too_large') })
|
error: req.i18n.translate('file_too_large'),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (err) return next(err)
|
if (err) return next(err)
|
||||||
if (!req.file?.path) {
|
if (!req.file?.path) {
|
||||||
logger.info({ req }, 'missing req.file.path on upload')
|
logger.info({ req }, 'missing req.file.path on upload')
|
||||||
return res
|
return sendUploadResponse(res, 400, {
|
||||||
.status(400)
|
success: false,
|
||||||
.json({ success: false, error: 'invalid_upload_request' })
|
error: 'invalid_upload_request',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,21 @@ import ProjectRootDocManager from '../Project/ProjectRootDocManager.mjs'
|
|||||||
import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.mjs'
|
import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.mjs'
|
||||||
import ProjectDeleter from '../Project/ProjectDeleter.mjs'
|
import ProjectDeleter from '../Project/ProjectDeleter.mjs'
|
||||||
import { DeletedProjectReasons } from '../Project/DeletedProjectReasons.mjs'
|
import { DeletedProjectReasons } from '../Project/DeletedProjectReasons.mjs'
|
||||||
|
import ProjectOptionsHandler from '../Project/ProjectOptionsHandler.mjs'
|
||||||
import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.mjs'
|
import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.mjs'
|
||||||
import logger from '@overleaf/logger'
|
import logger from '@overleaf/logger'
|
||||||
import OError from '@overleaf/o-error'
|
import OError from '@overleaf/o-error'
|
||||||
|
|
||||||
|
const COMPILER_BY_EXTENSION = {
|
||||||
|
tex: 'pdflatex',
|
||||||
|
rtex: 'pdflatex',
|
||||||
|
ltx: 'pdflatex',
|
||||||
|
rnw: 'pdflatex',
|
||||||
|
typ: 'typst',
|
||||||
|
qmd: 'quarto',
|
||||||
|
rmd: 'quarto',
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
createProjectFromZipArchive: callbackify(createProjectFromZipArchive),
|
createProjectFromZipArchive: callbackify(createProjectFromZipArchive),
|
||||||
createProjectFromZipArchiveWithName: callbackify(
|
createProjectFromZipArchiveWithName: callbackify(
|
||||||
@@ -52,6 +63,11 @@ async function createProjectFromZipArchive(ownerId, defaultName, zipPath) {
|
|||||||
project._id,
|
project._id,
|
||||||
path
|
path
|
||||||
)
|
)
|
||||||
|
const ext = path.split('.').pop()?.toLowerCase()
|
||||||
|
const compiler = ext ? COMPILER_BY_EXTENSION[ext] : null
|
||||||
|
if (compiler) {
|
||||||
|
await ProjectOptionsHandler.promises.setCompiler(project._id, compiler)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// no need to wait for the cleanup here
|
// no need to wait for the cleanup here
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
|
|||||||
import Settings from '@overleaf/settings'
|
import Settings from '@overleaf/settings'
|
||||||
import AsyncLocalStorage from '../../infrastructure/AsyncLocalStorage.mjs'
|
import AsyncLocalStorage from '../../infrastructure/AsyncLocalStorage.mjs'
|
||||||
|
|
||||||
|
// Sends HTTP 200 + first chunk immediately so upstream proxies (Traefik, cloud
|
||||||
|
// LBs) don't timeout waiting for the first response byte during large uploads.
|
||||||
|
// Must come *after* auth/rate-limit middleware (those still return proper codes)
|
||||||
|
// but *before* multer so it fires while the request body is still streaming in.
|
||||||
|
// The upload handler ends the response with the actual JSON result as the final
|
||||||
|
// chunk; the client's getResponseData trims leading whitespace before parsing.
|
||||||
|
function startStreamingResponse(req, res, next) {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Transfer-Encoding': 'chunked',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
})
|
||||||
|
res.write('\n')
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
if (!res.writableEnded) res.write('\n')
|
||||||
|
}, 30000)
|
||||||
|
res.on('finish', () => clearInterval(heartbeat))
|
||||||
|
res.on('close', () => clearInterval(heartbeat))
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
const rateLimiters = {
|
const rateLimiters = {
|
||||||
projectUpload: new RateLimiter('project-upload', {
|
projectUpload: new RateLimiter('project-upload', {
|
||||||
points: 20,
|
points: 20,
|
||||||
@@ -62,6 +83,7 @@ export default {
|
|||||||
fileUploadRateLimit,
|
fileUploadRateLimit,
|
||||||
AsyncLocalStorage.middleware,
|
AsyncLocalStorage.middleware,
|
||||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||||
|
startStreamingResponse,
|
||||||
ProjectUploadController.multerMiddleware,
|
ProjectUploadController.multerMiddleware,
|
||||||
ProjectUploadController.uploadFile
|
ProjectUploadController.uploadFile
|
||||||
)
|
)
|
||||||
@@ -72,6 +94,7 @@ export default {
|
|||||||
AuthenticationController.requireLogin(),
|
AuthenticationController.requireLogin(),
|
||||||
AsyncLocalStorage.middleware,
|
AsyncLocalStorage.middleware,
|
||||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||||
|
startStreamingResponse,
|
||||||
ProjectUploadController.multerMiddleware,
|
ProjectUploadController.multerMiddleware,
|
||||||
ProjectUploadController.uploadFile
|
ProjectUploadController.uploadFile
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import Translations from '../../infrastructure/Translations.mjs'
|
||||||
import UserHandler from './UserHandler.mjs'
|
import UserHandler from './UserHandler.mjs'
|
||||||
import UserDeleter from './UserDeleter.mjs'
|
import UserDeleter from './UserDeleter.mjs'
|
||||||
import UserGetter from './UserGetter.mjs'
|
import UserGetter from './UserGetter.mjs'
|
||||||
@@ -562,6 +564,30 @@ async function expireDeletedUsersAfterDuration(req, res, next) {
|
|||||||
res.sendStatus(204)
|
res.sendStatus(204)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setLanguage(req, res) {
|
||||||
|
const lngCode = req.body.lngCode ?? req.query.lng
|
||||||
|
if (!Translations.availableLanguageCodes.includes(lngCode)) {
|
||||||
|
return res.status(400).end()
|
||||||
|
}
|
||||||
|
res.cookie(Translations.LANG_COOKIE_NAME, lngCode, {
|
||||||
|
maxAge: 365 * 24 * 60 * 60 * 1000,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: Settings.secureCookie,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
domain: Settings.cookieDomain,
|
||||||
|
})
|
||||||
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
if (userId) {
|
||||||
|
await User.findByIdAndUpdate(userId, { languageCode: lngCode }).exec()
|
||||||
|
}
|
||||||
|
const returnTo = req.query.return_to
|
||||||
|
const redir =
|
||||||
|
typeof returnTo === 'string' && returnTo.startsWith('/')
|
||||||
|
? returnTo
|
||||||
|
: req.get('Referer') || '/project'
|
||||||
|
res.redirect(302, redir)
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
clearSessions: expressify(clearSessions),
|
clearSessions: expressify(clearSessions),
|
||||||
changePassword: expressify(changePassword),
|
changePassword: expressify(changePassword),
|
||||||
@@ -574,4 +600,5 @@ export default {
|
|||||||
expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration),
|
expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration),
|
||||||
ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware),
|
ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware),
|
||||||
ensureAffiliation,
|
ensureAffiliation,
|
||||||
|
setLanguage: expressify(setLanguage),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { fetchJson } from '@overleaf/fetch-utils'
|
|||||||
import contentDisposition from 'content-disposition'
|
import contentDisposition from 'content-disposition'
|
||||||
import Features from './Features.mjs'
|
import Features from './Features.mjs'
|
||||||
import SessionManager from '../Features/Authentication/SessionManager.mjs'
|
import SessionManager from '../Features/Authentication/SessionManager.mjs'
|
||||||
|
import UserGetter from '../Features/User/UserGetter.mjs'
|
||||||
|
import UserSettingsHelper from '../Features/Project/UserSettingsHelper.mjs'
|
||||||
import PackageVersions from './PackageVersions.js'
|
import PackageVersions from './PackageVersions.js'
|
||||||
import Modules from './Modules.mjs'
|
import Modules from './Modules.mjs'
|
||||||
import Errors from '../Features/Errors/Errors.js'
|
import Errors from '../Features/Errors/Errors.js'
|
||||||
@@ -15,6 +17,7 @@ import AdminAuthorizationHelper from '../Features/Helpers/AdminAuthorizationHelp
|
|||||||
import { addOptionalCleanupHandlerAfterDrainingConnections } from './GracefulShutdown.mjs'
|
import { addOptionalCleanupHandlerAfterDrainingConnections } from './GracefulShutdown.mjs'
|
||||||
import { sanitizeSessionUserForFrontEnd } from './FrontEndUser.mjs'
|
import { sanitizeSessionUserForFrontEnd } from './FrontEndUser.mjs'
|
||||||
import { expressify } from '@overleaf/promise-utils'
|
import { expressify } from '@overleaf/promise-utils'
|
||||||
|
import Translations from './Translations.mjs'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
canRedirectToAdminDomain,
|
canRedirectToAdminDomain,
|
||||||
@@ -269,6 +272,28 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
|
|||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
webRouter.use(
|
||||||
|
expressify(async function (req, res, next) {
|
||||||
|
res.locals.isLumiere = false
|
||||||
|
const sessionUser = SessionManager.getSessionUser(req.session)
|
||||||
|
if (sessionUser?._id) {
|
||||||
|
try {
|
||||||
|
const user = await UserGetter.promises.getUser(sessionUser._id, {
|
||||||
|
'ace.overallTheme': 1,
|
||||||
|
signUpDate: 1,
|
||||||
|
})
|
||||||
|
if (user) {
|
||||||
|
res.locals.isLumiere =
|
||||||
|
UserSettingsHelper.getOverallTheme(user) === 'lumiere-'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err }, 'failed to fetch theme for isLumiere')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
webRouter.use(function (req, res, next) {
|
webRouter.use(function (req, res, next) {
|
||||||
res.locals.getLoggedInUserId = () =>
|
res.locals.getLoggedInUserId = () =>
|
||||||
SessionManager.getLoggedInUserId(req.session)
|
SessionManager.getLoggedInUserId(req.session)
|
||||||
@@ -307,11 +332,15 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
|
|||||||
webRouter.use(function (req, res, next) {
|
webRouter.use(function (req, res, next) {
|
||||||
res.locals.overallThemes = [
|
res.locals.overallThemes = [
|
||||||
{
|
{
|
||||||
name: 'Dark',
|
name: 'Verso Lumière',
|
||||||
|
val: 'lumiere-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Classic Dark',
|
||||||
val: '',
|
val: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Light',
|
name: 'Classic Light',
|
||||||
val: 'light-',
|
val: 'light-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -324,6 +353,7 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
|
|||||||
|
|
||||||
webRouter.use(function (req, res, next) {
|
webRouter.use(function (req, res, next) {
|
||||||
res.locals.settings = Settings
|
res.locals.settings = Settings
|
||||||
|
res.locals.availableLanguages = Translations.availableLanguageCodes
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ if (Settings.csp && Settings.csp.enabled) {
|
|||||||
|
|
||||||
logger.debug('creating HTTP server'.yellow)
|
logger.debug('creating HTTP server'.yellow)
|
||||||
const server = http.createServer(app)
|
const server = http.createServer(app)
|
||||||
|
server.requestTimeout = 15 * 60 * 1000
|
||||||
|
|
||||||
// provide settings for separate web and api processes
|
// provide settings for separate web and api processes
|
||||||
if (Settings.enabledServices.includes('api')) {
|
if (Settings.enabledServices.includes('api')) {
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ const locales = {
|
|||||||
'zh-CN': zhCN,
|
'zh-CN': zhCN,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LANG_COOKIE_NAME = 'verso-lang'
|
||||||
|
|
||||||
const fallbackLanguageCode = Settings.i18n.defaultLng || 'en'
|
const fallbackLanguageCode = Settings.i18n.defaultLng || 'en'
|
||||||
const availableLanguageCodes = []
|
const availableLanguageCodes = []
|
||||||
const availableHosts = new Map()
|
const availableHosts = new Map()
|
||||||
@@ -62,15 +64,20 @@ Object.values(Settings.i18n.subdomainLang || {}).forEach(function (spec) {
|
|||||||
subdomainConfigs.set(spec.lngCode, spec)
|
subdomainConfigs.set(spec.lngCode, spec)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (!availableLanguageCodes.includes(fallbackLanguageCode)) {
|
// Make all bundled locale files available regardless of subdomain config.
|
||||||
// always load the fallback locale
|
// This allows the cookie-based language picker to offer every loaded locale
|
||||||
availableLanguageCodes.push(fallbackLanguageCode)
|
// even when no subdomains are configured.
|
||||||
|
for (const lngCode of Object.keys(locales)) {
|
||||||
|
if (!availableLanguageCodes.includes(lngCode)) {
|
||||||
|
availableLanguageCodes.push(lngCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resources = Object.fromEntries(
|
const resources = Object.fromEntries(
|
||||||
Object.entries(locales)
|
Object.entries(locales).map(([lngCode, translations]) => [
|
||||||
.filter(([lngCode]) => availableLanguageCodes.includes(lngCode))
|
lngCode,
|
||||||
.map(([lngCode, translations]) => [lngCode, { translation: translations }])
|
{ translation: translations },
|
||||||
|
])
|
||||||
)
|
)
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
@@ -107,8 +114,13 @@ i18n
|
|||||||
})
|
})
|
||||||
|
|
||||||
function setLangBasedOnDomainMiddleware(req, res, next) {
|
function setLangBasedOnDomainMiddleware(req, res, next) {
|
||||||
// Determine language from subdomain
|
// Priority: (1) user-set cookie, (2) subdomain, (3) env-var default
|
||||||
const lang = availableHosts.get(req.headers.host) ?? fallbackLanguageCode
|
const cookieLng = req.cookies[LANG_COOKIE_NAME]
|
||||||
|
const hostLng = availableHosts.get(req.headers.host)
|
||||||
|
const lang =
|
||||||
|
(availableLanguageCodes.includes(cookieLng) ? cookieLng : null) ??
|
||||||
|
hostLng ??
|
||||||
|
fallbackLanguageCode
|
||||||
|
|
||||||
req.i18n = {
|
req.i18n = {
|
||||||
language: lang,
|
language: lang,
|
||||||
@@ -173,4 +185,6 @@ i18n.translate = i18n.t
|
|||||||
export default {
|
export default {
|
||||||
setLangBasedOnDomainMiddleware,
|
setLangBasedOnDomainMiddleware,
|
||||||
i18n,
|
i18n,
|
||||||
|
LANG_COOKIE_NAME,
|
||||||
|
availableLanguageCodes,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const ProjectSchema = new Schema(
|
|||||||
version: { type: Number }, // incremented for every change in the project structure (folders and filenames)
|
version: { type: Number }, // incremented for every change in the project structure (folders and filenames)
|
||||||
publicAccesLevel: { type: String, default: 'private' },
|
publicAccesLevel: { type: String, default: 'private' },
|
||||||
compiler: { type: String, default: settings.defaultLatexCompiler },
|
compiler: { type: String, default: settings.defaultLatexCompiler },
|
||||||
|
quartoFlavor: { type: String, enum: ['revealjs', 'pdf'] },
|
||||||
spellCheckLanguage: { type: String, default: 'en' },
|
spellCheckLanguage: { type: String, default: 'en' },
|
||||||
deletedByExternalDataSource: { type: Boolean, default: false },
|
deletedByExternalDataSource: { type: Boolean, default: false },
|
||||||
description: { type: String, default: '' },
|
description: { type: String, default: '' },
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const UserSchema = new Schema(
|
|||||||
},
|
},
|
||||||
role: { type: String, default: '' },
|
role: { type: String, default: '' },
|
||||||
institution: { type: String, default: '' },
|
institution: { type: String, default: '' },
|
||||||
|
languageCode: { type: String, default: null },
|
||||||
hashedPassword: String,
|
hashedPassword: String,
|
||||||
enrollment: {
|
enrollment: {
|
||||||
sso: [
|
sso: [
|
||||||
|
|||||||
@@ -284,6 +284,14 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||||||
'/read-only/one-time-login'
|
'/read-only/one-time-login'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Language preference — works for both anonymous (GET) and logged-in (POST)
|
||||||
|
webRouter.get('/set-language', UserController.setLanguage)
|
||||||
|
webRouter.post(
|
||||||
|
'/user/language',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
UserController.setLanguage
|
||||||
|
)
|
||||||
|
|
||||||
webRouter.get('/logout', UserPagesController.logout)
|
webRouter.get('/logout', UserPagesController.logout)
|
||||||
webRouter.post('/logout', UserController.logout)
|
webRouter.post('/logout', UserController.logout)
|
||||||
|
|
||||||
@@ -611,6 +619,12 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||||||
CompileController.stopCompile
|
CompileController.stopCompile
|
||||||
)
|
)
|
||||||
|
|
||||||
|
webRouter.get(
|
||||||
|
'/project/:Project_id/thumbnail',
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||||
|
CompileController.getProjectThumbnail
|
||||||
|
)
|
||||||
|
|
||||||
webRouter.get(
|
webRouter.get(
|
||||||
'/project/:Project_id/output/cached/output.overleaf.json',
|
'/project/:Project_id/output/cached/output.overleaf.json',
|
||||||
AsyncLocalStorage.middleware,
|
AsyncLocalStorage.middleware,
|
||||||
@@ -681,17 +695,17 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||||||
)
|
)
|
||||||
webRouter.post(
|
webRouter.post(
|
||||||
'/project/:Project_id/publish-presentation',
|
'/project/:Project_id/publish-presentation',
|
||||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
AuthorizationMiddleware.ensureUserCanAdminProject,
|
||||||
PublishedPresentationController.publish
|
PublishedPresentationController.publish
|
||||||
)
|
)
|
||||||
webRouter.post(
|
webRouter.post(
|
||||||
'/project/:Project_id/publish-presentation/regenerate',
|
'/project/:Project_id/publish-presentation/regenerate',
|
||||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
AuthorizationMiddleware.ensureUserCanAdminProject,
|
||||||
PublishedPresentationController.regenerate
|
PublishedPresentationController.regenerate
|
||||||
)
|
)
|
||||||
webRouter.delete(
|
webRouter.delete(
|
||||||
'/project/:Project_id/publish-presentation',
|
'/project/:Project_id/publish-presentation',
|
||||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
AuthorizationMiddleware.ensureUserCanAdminProject,
|
||||||
PublishedPresentationController.unpublish
|
PublishedPresentationController.unpublish
|
||||||
)
|
)
|
||||||
// On-demand export of a RevealJS deck (download menu): html | pdf.
|
// On-demand export of a RevealJS deck (download menu): html | pdf.
|
||||||
@@ -822,6 +836,12 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||||
ProjectDownloadsController.downloadPreparedProjectExport
|
ProjectDownloadsController.downloadPreparedProjectExport
|
||||||
)
|
)
|
||||||
|
webRouter.post(
|
||||||
|
'/project/:Project_id/doc/:Doc_id/convert/:type',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||||
|
ProjectDownloadsController.convertDocInProject
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
webRouter.get(
|
webRouter.get(
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ else if settings.overleaf
|
|||||||
meta(itemprop='image' content=buildImgPath('ol-brand/overleaf_og_logo.png'))
|
meta(itemprop='image' content=buildImgPath('ol-brand/overleaf_og_logo.png'))
|
||||||
meta(name='image' content=buildImgPath('ol-brand/overleaf_og_logo.png'))
|
meta(name='image' content=buildImgPath('ol-brand/overleaf_og_logo.png'))
|
||||||
else
|
else
|
||||||
//- the default image for Overleaf Community Edition/Server Pro
|
//- the default image for Verso Community Edition
|
||||||
meta(itemprop='image' content=buildBaseAssetPath() + 'apple-touch-icon.png')
|
meta(itemprop='image' content=buildBaseAssetPath() + 'og-image.png')
|
||||||
meta(name='image' content=buildBaseAssetPath() + 'apple-touch-icon.png')
|
meta(name='image' content=buildBaseAssetPath() + 'og-image.png')
|
||||||
|
|
||||||
//- Keywords
|
//- Keywords
|
||||||
if metadata && metadata.keywords
|
if metadata && metadata.keywords
|
||||||
@@ -78,14 +78,17 @@ else if settings.overleaf
|
|||||||
content=buildImgPath('ol-brand/overleaf_og_logo.png')
|
content=buildImgPath('ol-brand/overleaf_og_logo.png')
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
//- the default image for Overleaf Community Edition/Server Pro
|
//- the default image for Verso Community Edition
|
||||||
meta(
|
meta(
|
||||||
name='twitter:image'
|
name='twitter:image'
|
||||||
content=buildBaseAssetPath() + 'apple-touch-icon.png'
|
content=buildBaseAssetPath() + 'og-image.png'
|
||||||
)
|
)
|
||||||
|
|
||||||
//- Open Graph
|
//- Open Graph
|
||||||
//- to do - add og:url
|
if metadata && metadata.canonicalURL
|
||||||
|
meta(property='og:url' content=metadata.canonicalURL)
|
||||||
|
else if typeof currentUrl !== 'undefined'
|
||||||
|
meta(property='og:url' content=settings.siteUrl + currentUrl)
|
||||||
if settings.social && settings.social.facebook && settings.social.facebook.appId
|
if settings.social && settings.social.facebook && settings.social.facebook.appId
|
||||||
meta(property='fb:app_id' content=settings.social.facebook.appId)
|
meta(property='fb:app_id' content=settings.social.facebook.appId)
|
||||||
|
|
||||||
@@ -104,14 +107,14 @@ else if settings.overleaf
|
|||||||
content=buildImgPath('ol-brand/overleaf_og_logo.png')
|
content=buildImgPath('ol-brand/overleaf_og_logo.png')
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
//- the default image for Overleaf Community Edition/Server Pro
|
//- the default image for Verso Community Edition
|
||||||
meta(
|
meta(
|
||||||
property='og:image'
|
property='og:image'
|
||||||
content=buildBaseAssetPath() + 'apple-touch-icon.png'
|
content=buildBaseAssetPath() + 'og-image.png'
|
||||||
)
|
)
|
||||||
|
|
||||||
if metadata && metadata.openGraphType
|
if metadata && metadata.openGraphType
|
||||||
meta(property='og:type' metadata.openGraphType)
|
meta(property='og:type' content=metadata.openGraphType)
|
||||||
else
|
else
|
||||||
meta(property='og:type' content='website')
|
meta(property='og:type' content='website')
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ html(
|
|||||||
'red-nav-bar-for-admins': !settings.isDevEnv && hasFeature('saas') && hasAdminAccess(),
|
'red-nav-bar-for-admins': !settings.isDevEnv && hasFeature('saas') && hasAdminAccess(),
|
||||||
}
|
}
|
||||||
data-theme='light'
|
data-theme='light'
|
||||||
|
data-lumiere=isLumiere ? 'true' : 'false'
|
||||||
)
|
)
|
||||||
if settings.recaptcha && settings.recaptcha.siteKeyV3
|
if settings.recaptcha && settings.recaptcha.siteKeyV3
|
||||||
script(
|
script(
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ block append meta
|
|||||||
showPoweredBy: !hasFeature('saas') && !settings.nav.hide_powered_by,
|
showPoweredBy: !hasFeature('saas') && !settings.nav.hide_powered_by,
|
||||||
subdomainLang: settings.i18n.subdomainLang,
|
subdomainLang: settings.i18n.subdomainLang,
|
||||||
translatedLanguages: settings.translatedLanguages,
|
translatedLanguages: settings.translatedLanguages,
|
||||||
|
availableLanguages: availableLanguages,
|
||||||
leftItems: cloneAndTranslateText(settings.nav.left_footer),
|
leftItems: cloneAndTranslateText(settings.nav.left_footer),
|
||||||
rightItems: settings.nav.right_footer,
|
rightItems: settings.nav.right_footer,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,43 @@
|
|||||||
include ../_mixins/material_symbol
|
li.language-picker
|
||||||
|
details.language-picker-details
|
||||||
li.dropdown.dropup.subdued.language-picker(dropdown)
|
summary#language-picker-toggle.btn-inline-link(
|
||||||
button#language-picker-toggle.btn.btn-link.btn-inline-link(
|
aria-label=translate('select_a_language')
|
||||||
dropdown-toggle
|
translate='no'
|
||||||
data-ol-lang-selector-tooltip
|
)
|
||||||
data-bs-toggle='dropdown'
|
span.material-symbols translate
|
||||||
aria-haspopup='true'
|
|
|
||||||
aria-expanded='false'
|
span.language-picker-text= settings.translatedLanguages[currentLngCode] || currentLngCode
|
||||||
aria-label='Select ' + translate('language')
|
ul.dropdown-menu.dropdown-menu-sm-width(
|
||||||
tooltip=translate('language')
|
role='menu'
|
||||||
title=translate('language')
|
aria-labelledby='language-picker-toggle'
|
||||||
)
|
translate='no'
|
||||||
+material-symbol('translate')
|
)
|
||||||
|
|
each lngCode in availableLanguages
|
||||||
span.language-picker-text #{settings.translatedLanguages[currentLngCode]}
|
if settings.translatedLanguages[lngCode]
|
||||||
|
li(role='none')
|
||||||
ul.dropdown-menu.dropdown-menu-sm-width(
|
a.dropdown-item(
|
||||||
role='menu'
|
role='menuitem'
|
||||||
aria-labelledby='language-picker-toggle'
|
href='/set-language?lng=' + encodeURIComponent(lngCode) + '&return_to=/'
|
||||||
)
|
data-lng=lngCode
|
||||||
li.dropdown-header #{translate("language")}
|
class=lngCode === currentLngCode ? 'active' : ''
|
||||||
each subdomainDetails, subdomain in settings.i18n.subdomainLang
|
)= settings.translatedLanguages[lngCode]
|
||||||
if !subdomainDetails.hide
|
script.
|
||||||
- let isActive = subdomainDetails.lngCode === currentLngCode
|
(function () {
|
||||||
li.lng-option
|
var details = document.querySelector('.language-picker-details')
|
||||||
a.menu-indent(
|
var menu = details && details.querySelector('.dropdown-menu')
|
||||||
href=subdomainDetails.url + currentUrlWithQueryParams
|
if (!details || !menu) return
|
||||||
role='menuitem'
|
menu.querySelectorAll('a[data-lng]').forEach(function (a) {
|
||||||
class=['dropdown-item', {active: isActive}]
|
var lng = a.getAttribute('data-lng')
|
||||||
aria-selected=isActive ? 'true' : 'false'
|
a.href =
|
||||||
)
|
'/set-language?lng=' +
|
||||||
| #{settings.translatedLanguages[subdomainDetails.lngCode]}
|
encodeURIComponent(lng) +
|
||||||
if subdomainDetails.lngCode === currentLngCode
|
'&return_to=' +
|
||||||
+material-symbol('check', 'dropdown-item-trailing-icon')
|
encodeURIComponent(window.location.pathname)
|
||||||
|
})
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!details.contains(e.target)) details.open = false
|
||||||
|
})
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') details.open = false
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
footer.site-footer
|
footer.site-footer
|
||||||
- var showLanguagePicker = Object.keys(settings.i18n.subdomainLang).length > 1
|
- var showLanguagePicker = availableLanguages.length > 1
|
||||||
- var hasCustomLeftNav = nav.left_footer && nav.left_footer.length > 0
|
- var hasCustomLeftNav = nav.left_footer && nav.left_footer.length > 0
|
||||||
.site-footer-content.hidden-print
|
.site-footer-content.hidden-print
|
||||||
.row
|
.row
|
||||||
@@ -8,22 +8,22 @@ footer.site-footer
|
|||||||
| © #{new Date().getFullYear()}
|
| © #{new Date().getFullYear()}
|
||||||
|
|
|
|
||||||
a(href='https://alocoq.fr' target='_blank' rel='noopener noreferrer') Aloïs Coquillard
|
a(href='https://alocoq.fr' target='_blank' rel='noopener noreferrer') Aloïs Coquillard
|
||||||
li
|
li.footer-sep
|
||||||
strong.text-muted |
|
strong.text-muted |
|
||||||
li
|
li
|
||||||
| Built on
|
| !{translate('built_on')}
|
||||||
|
|
|
|
||||||
a(href='https://github.com/overleaf/overleaf' target='_blank' rel='noopener noreferrer') Overleaf
|
a(href='https://github.com/overleaf/overleaf' target='_blank' rel='noopener noreferrer') Overleaf
|
||||||
|
|
||||||
if showLanguagePicker || hasCustomLeftNav
|
if showLanguagePicker || hasCustomLeftNav
|
||||||
li
|
li.footer-sep
|
||||||
strong.text-muted |
|
strong.text-muted |
|
||||||
|
|
||||||
if showLanguagePicker
|
if showLanguagePicker
|
||||||
include language-picker
|
include language-picker
|
||||||
|
|
||||||
if showLanguagePicker && hasCustomLeftNav
|
if showLanguagePicker && hasCustomLeftNav
|
||||||
li
|
li.footer-sep
|
||||||
strong.text-muted |
|
strong.text-muted |
|
||||||
|
|
||||||
each item in nav.left_footer
|
each item in nav.left_footer
|
||||||
@@ -33,10 +33,10 @@ footer.site-footer
|
|||||||
else
|
else
|
||||||
| !{item.text}
|
| !{item.text}
|
||||||
|
|
||||||
ul.site-footer-items.col-lg-3.text-end
|
ul.site-footer-items.col-lg-3.text-lg-end
|
||||||
li
|
li
|
||||||
a(href='https://git.alocoq.fr/alois/verso/src/branch/main/LICENSE' target='_blank' rel='noopener noreferrer') AGPL licence
|
a(href='https://git.alocoq.fr/alois/verso/src/branch/main/LICENSE' target='_blank' rel='noopener noreferrer') AGPL licence
|
||||||
li
|
li.footer-sep
|
||||||
strong.text-muted |
|
strong.text-muted |
|
||||||
li
|
li
|
||||||
a(href='https://git.alocoq.fr/alois/verso' target='_blank' rel='noopener noreferrer') Source code
|
a(href='https://git.alocoq.fr/alois/verso' target='_blank' rel='noopener noreferrer') Source code
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ block vars
|
|||||||
- var suppressNavbar = true
|
- var suppressNavbar = true
|
||||||
|
|
||||||
block content
|
block content
|
||||||
main#main-content.content
|
main#main-content.content.login-page
|
||||||
.container
|
.container
|
||||||
.row
|
.row
|
||||||
.col-12
|
.col-12
|
||||||
.text-center.mb-4
|
.lumiere-logo-center.mb-4
|
||||||
img.verso-login-logo(
|
img.verso-login-logo(
|
||||||
src=buildImgPath('ol-brand/verso-logo.svg')
|
src=buildImgPath('ol-brand/verso-logo.svg')
|
||||||
alt='Verso'
|
alt='Verso'
|
||||||
style='width:100%;max-width:480px;height:auto'
|
style='width:100%;height:auto'
|
||||||
)
|
)
|
||||||
.row
|
.row
|
||||||
.col-lg-6.offset-lg-3.col-xl-4.offset-xl-4
|
.login-lumiere-card.col-lg-6.offset-lg-3.col-xl-4.offset-xl-4
|
||||||
.page-header
|
.page-header
|
||||||
if login_support_title
|
if login_support_title
|
||||||
h1 !{login_support_title}
|
h1 !{login_support_title}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ block content
|
|||||||
|
|
||||||
main#main-content(data-ol-captcha-retry-trigger-area='')
|
main#main-content(data-ol-captcha-retry-trigger-area='')
|
||||||
a.auth-aux-logo(href='/')
|
a.auth-aux-logo(href='/')
|
||||||
img(src=buildImgPath('ol-brand/overleaf-o-dark.svg') alt=settings.appName)
|
img(src=buildImgPath('ol-brand/verso-square.svg') alt='Verso')
|
||||||
.auth-aux-container
|
.auth-aux-container
|
||||||
form(
|
form(
|
||||||
name='passwordResetForm'
|
name='passwordResetForm'
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ block vars
|
|||||||
block content
|
block content
|
||||||
main#main-content
|
main#main-content
|
||||||
.auth-aux-container
|
.auth-aux-container
|
||||||
img.w-50.d-block(
|
img.d-block(
|
||||||
src=buildImgPath('ol-brand/overleaf.svg')
|
src=buildImgPath('ol-brand/verso-logo.svg')
|
||||||
alt=settings.appName
|
alt='Verso'
|
||||||
|
style='height:36px;width:auto;margin-bottom:1.5rem'
|
||||||
)
|
)
|
||||||
h1.h3.mb-3 #{translate("keep_your_account_safe")}
|
h1.h3.mb-3 #{translate("keep_your_account_safe")}
|
||||||
div(data-ol-multi-submit)
|
div(data-ol-multi-submit)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ block vars
|
|||||||
block content
|
block content
|
||||||
main#main-content
|
main#main-content
|
||||||
a.auth-aux-logo(href='/')
|
a.auth-aux-logo(href='/')
|
||||||
img(src=buildImgPath('ol-brand/overleaf-o-dark.svg') alt=settings.appName)
|
img(src=buildImgPath('ol-brand/verso-square.svg') alt='Verso')
|
||||||
.auth-aux-container
|
.auth-aux-container
|
||||||
form(
|
form(
|
||||||
name='passwordResetForm'
|
name='passwordResetForm'
|
||||||
|
|||||||
@@ -77,8 +77,8 @@
|
|||||||
"add_another_address_line": "",
|
"add_another_address_line": "",
|
||||||
"add_another_email": "",
|
"add_another_email": "",
|
||||||
"add_another_token": "",
|
"add_another_token": "",
|
||||||
"add_comma_separated_emails_help": "",
|
|
||||||
"add_collaborators": "",
|
"add_collaborators": "",
|
||||||
|
"add_comma_separated_emails_help": "",
|
||||||
"add_comment": "",
|
"add_comment": "",
|
||||||
"add_comment_error_message": "",
|
"add_comment_error_message": "",
|
||||||
"add_comment_error_title": "",
|
"add_comment_error_title": "",
|
||||||
@@ -117,6 +117,7 @@
|
|||||||
"after_that_well_bill_you_x_total_y_subtotal_z_tax_annually_on_date_unless_you_cancel": "",
|
"after_that_well_bill_you_x_total_y_subtotal_z_tax_annually_on_date_unless_you_cancel": "",
|
||||||
"aggregate_changed": "",
|
"aggregate_changed": "",
|
||||||
"aggregate_to": "",
|
"aggregate_to": "",
|
||||||
|
"agpl_licence": "",
|
||||||
"agree": "",
|
"agree": "",
|
||||||
"agree_with_the_terms": "",
|
"agree_with_the_terms": "",
|
||||||
"ai_assist_in_overleaf_is_included_via_writefull_groups": "",
|
"ai_assist_in_overleaf_is_included_via_writefull_groups": "",
|
||||||
@@ -228,6 +229,7 @@
|
|||||||
"breadcrumbs": "",
|
"breadcrumbs": "",
|
||||||
"browser": "",
|
"browser": "",
|
||||||
"build_collection_of_most_used_references": "",
|
"build_collection_of_most_used_references": "",
|
||||||
|
"built_on": "",
|
||||||
"bullet_list": "",
|
"bullet_list": "",
|
||||||
"buy_licenses": "",
|
"buy_licenses": "",
|
||||||
"buy_more_licenses": "",
|
"buy_more_licenses": "",
|
||||||
@@ -385,6 +387,8 @@
|
|||||||
"continue_using_free_features": "",
|
"continue_using_free_features": "",
|
||||||
"continue_with_free_plan": "",
|
"continue_with_free_plan": "",
|
||||||
"conversion_error_details": "",
|
"conversion_error_details": "",
|
||||||
|
"convert_to_latex": "",
|
||||||
|
"convert_to_typst": "",
|
||||||
"cookie_banner": "",
|
"cookie_banner": "",
|
||||||
"cookie_banner_info": "",
|
"cookie_banner_info": "",
|
||||||
"copied": "",
|
"copied": "",
|
||||||
@@ -471,6 +475,8 @@
|
|||||||
"demonstrating_track_changes_feature": "",
|
"demonstrating_track_changes_feature": "",
|
||||||
"department": "",
|
"department": "",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
"card_size": "",
|
||||||
|
"deselect_all": "",
|
||||||
"details": "",
|
"details": "",
|
||||||
"details_provided_by_google_explanation": "",
|
"details_provided_by_google_explanation": "",
|
||||||
"dictionary": "",
|
"dictionary": "",
|
||||||
@@ -665,7 +671,9 @@
|
|||||||
"explore_plans": "",
|
"explore_plans": "",
|
||||||
"export_as_docx": "",
|
"export_as_docx": "",
|
||||||
"export_as_html": "",
|
"export_as_html": "",
|
||||||
|
"export_as_latex": "",
|
||||||
"export_as_markdown": "",
|
"export_as_markdown": "",
|
||||||
|
"export_as_typst": "",
|
||||||
"export_csv": "",
|
"export_csv": "",
|
||||||
"export_project_to_github": "",
|
"export_project_to_github": "",
|
||||||
"failed": "",
|
"failed": "",
|
||||||
@@ -1010,6 +1018,7 @@
|
|||||||
"institution_templates": "",
|
"institution_templates": "",
|
||||||
"integrations": "",
|
"integrations": "",
|
||||||
"integrations_like_github": "",
|
"integrations_like_github": "",
|
||||||
|
"interface_language": "",
|
||||||
"interested_in_cheaper_personal_plan": "",
|
"interested_in_cheaper_personal_plan": "",
|
||||||
"invalid_confirmation_code": "",
|
"invalid_confirmation_code": "",
|
||||||
"invalid_email": "",
|
"invalid_email": "",
|
||||||
@@ -1093,6 +1102,7 @@
|
|||||||
"last_verified": "",
|
"last_verified": "",
|
||||||
"latam_discount_modal_info": "",
|
"latam_discount_modal_info": "",
|
||||||
"latam_discount_modal_title": "",
|
"latam_discount_modal_title": "",
|
||||||
|
"latex_export_feedback_message": "",
|
||||||
"latex_places_figures_according_to_a_special_algorithm": "",
|
"latex_places_figures_according_to_a_special_algorithm": "",
|
||||||
"latex_places_tables_according_to_a_special_algorithm": "",
|
"latex_places_tables_according_to_a_special_algorithm": "",
|
||||||
"layout_options": "",
|
"layout_options": "",
|
||||||
@@ -1266,6 +1276,8 @@
|
|||||||
"n_more_updates_above_plural": "",
|
"n_more_updates_above_plural": "",
|
||||||
"n_more_updates_below": "",
|
"n_more_updates_below": "",
|
||||||
"n_more_updates_below_plural": "",
|
"n_more_updates_below_plural": "",
|
||||||
|
"n_projects_selected": "",
|
||||||
|
"n_projects_selected_plural": "",
|
||||||
"name": "",
|
"name": "",
|
||||||
"name_usage_explanation": "",
|
"name_usage_explanation": "",
|
||||||
"navigation": "",
|
"navigation": "",
|
||||||
@@ -1466,8 +1478,6 @@
|
|||||||
"please_ask_the_project_owner_to_upgrade_to_track_changes": "",
|
"please_ask_the_project_owner_to_upgrade_to_track_changes": "",
|
||||||
"please_change_primary_to_remove": "",
|
"please_change_primary_to_remove": "",
|
||||||
"please_compile_pdf_before_download": "",
|
"please_compile_pdf_before_download": "",
|
||||||
"python_packages": "",
|
|
||||||
"python_packages_help": "",
|
|
||||||
"please_confirm_primary_email_or_edit": "",
|
"please_confirm_primary_email_or_edit": "",
|
||||||
"please_confirm_secondary_email_or_edit": "",
|
"please_confirm_secondary_email_or_edit": "",
|
||||||
"please_confirm_your_email_before_making_it_default": "",
|
"please_confirm_your_email_before_making_it_default": "",
|
||||||
@@ -1502,14 +1512,14 @@
|
|||||||
"premium_plan_label": "",
|
"premium_plan_label": "",
|
||||||
"preparing_for_export": "",
|
"preparing_for_export": "",
|
||||||
"preparing_your_download": "",
|
"preparing_your_download": "",
|
||||||
|
"present": "",
|
||||||
|
"present_publishes_and_opens_in_new_tab": "",
|
||||||
"presentation_export_can_take_a_moment": "",
|
"presentation_export_can_take_a_moment": "",
|
||||||
"presentation_export_failed": "",
|
"presentation_export_failed": "",
|
||||||
"presentation_link_members": "",
|
"presentation_link_members": "",
|
||||||
"presentation_link_private": "",
|
"presentation_link_private": "",
|
||||||
"presentation_link_public": "",
|
"presentation_link_public": "",
|
||||||
"presentation_mode": "",
|
"presentation_mode": "",
|
||||||
"present": "",
|
|
||||||
"present_publishes_and_opens_in_new_tab": "",
|
|
||||||
"press_shift_space_for_suggestions": "",
|
"press_shift_space_for_suggestions": "",
|
||||||
"press_space_to_open_the_ai_assistant": "",
|
"press_space_to_open_the_ai_assistant": "",
|
||||||
"preview": "",
|
"preview": "",
|
||||||
@@ -1582,6 +1592,8 @@
|
|||||||
"pull_github_changes_into_sharelatex": "",
|
"pull_github_changes_into_sharelatex": "",
|
||||||
"push_sharelatex_changes_to_github": "",
|
"push_sharelatex_changes_to_github": "",
|
||||||
"push_to_github_pull_to_overleaf": "",
|
"push_to_github_pull_to_overleaf": "",
|
||||||
|
"python_packages": "",
|
||||||
|
"python_packages_help": "",
|
||||||
"quoted_text": "",
|
"quoted_text": "",
|
||||||
"raw_logs": "",
|
"raw_logs": "",
|
||||||
"raw_logs_description": "",
|
"raw_logs_description": "",
|
||||||
@@ -1906,6 +1918,7 @@
|
|||||||
"sort_by": "",
|
"sort_by": "",
|
||||||
"sort_by_x": "",
|
"sort_by_x": "",
|
||||||
"sort_projects": "",
|
"sort_projects": "",
|
||||||
|
"source_code": "",
|
||||||
"speak": "",
|
"speak": "",
|
||||||
"speech_input_not_available": "",
|
"speech_input_not_available": "",
|
||||||
"spellcheck": "",
|
"spellcheck": "",
|
||||||
@@ -2214,6 +2227,7 @@
|
|||||||
"tooltip_hide_pdf": "",
|
"tooltip_hide_pdf": "",
|
||||||
"tooltip_show_panel": "",
|
"tooltip_show_panel": "",
|
||||||
"tooltip_show_pdf": "",
|
"tooltip_show_pdf": "",
|
||||||
|
"top_bottom_split_view": "",
|
||||||
"total_due_in_x_days": "",
|
"total_due_in_x_days": "",
|
||||||
"total_due_today": "",
|
"total_due_today": "",
|
||||||
"total_per_month": "",
|
"total_per_month": "",
|
||||||
@@ -2250,6 +2264,7 @@
|
|||||||
"turn_off_link_sharing": "",
|
"turn_off_link_sharing": "",
|
||||||
"turn_on": "",
|
"turn_on": "",
|
||||||
"turn_on_link_sharing": "",
|
"turn_on_link_sharing": "",
|
||||||
|
"typst_export_feedback_message": "",
|
||||||
"unarchive": "",
|
"unarchive": "",
|
||||||
"uncategorized": "",
|
"uncategorized": "",
|
||||||
"uncategorized_projects": "",
|
"uncategorized_projects": "",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { usePermissionsContext } from '@/features/ide-react/context/permissions-
|
|||||||
import FileTreeActionButton from './file-tree-action-button'
|
import FileTreeActionButton from './file-tree-action-button'
|
||||||
import { useRailContext } from '../../ide-react/context/rail-context'
|
import { useRailContext } from '../../ide-react/context/rail-context'
|
||||||
import PythonRequirementsModal from './python-requirements-modal'
|
import PythonRequirementsModal from './python-requirements-modal'
|
||||||
|
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
|
||||||
|
|
||||||
export default function FileTreeActionButtons({
|
export default function FileTreeActionButtons({
|
||||||
fileTreeExpanded,
|
fileTreeExpanded,
|
||||||
@@ -19,6 +20,8 @@ export default function FileTreeActionButtons({
|
|||||||
const { write } = usePermissionsContext()
|
const { write } = usePermissionsContext()
|
||||||
const { handlePaneCollapse } = useRailContext()
|
const { handlePaneCollapse } = useRailContext()
|
||||||
const [showPythonModal, setShowPythonModal] = useState(false)
|
const [showPythonModal, setShowPythonModal] = useState(false)
|
||||||
|
const { compiler } = useProjectSettingsContext()
|
||||||
|
const isQuarto = compiler === 'quarto'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
canCreate,
|
canCreate,
|
||||||
@@ -112,7 +115,7 @@ export default function FileTreeActionButtons({
|
|||||||
iconType="delete"
|
iconType="delete"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{write && (
|
{write && isQuarto && (
|
||||||
<FileTreeActionButton
|
<FileTreeActionButton
|
||||||
id="python-packages"
|
id="python-packages"
|
||||||
description={t('python_packages')}
|
description={t('python_packages')}
|
||||||
|
|||||||
+18
@@ -173,6 +173,24 @@ export default function FileTreeUploadDoc() {
|
|||||||
// limit: maxConnections || 1,
|
// limit: maxConnections || 1,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
fieldName: 'qqfile', // "qqfile" field inherited from FineUploader
|
fieldName: 'qqfile', // "qqfile" field inherited from FineUploader
|
||||||
|
// The server sends HTTP 200 + a keepalive '\n' byte immediately to
|
||||||
|
// prevent upstream proxy timeouts on slow connections, then streams
|
||||||
|
// the actual JSON result as the final chunk. Trim before parsing.
|
||||||
|
getResponseData: (responseText: string) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(responseText.trim())
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
validateStatus: (statusCode: number, responseText: string) => {
|
||||||
|
if (statusCode < 200 || statusCode >= 300) return false
|
||||||
|
try {
|
||||||
|
return JSON.parse(responseText.trim()).success === true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
// close the modal when all the uploads completed successfully
|
// close the modal when all the uploads completed successfully
|
||||||
.on('complete', result => {
|
.on('complete', result => {
|
||||||
|
|||||||
+3
-1
@@ -30,7 +30,8 @@ function FileTreeItemInner({
|
|||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { fileTreeReadOnly } = useFileTreeData()
|
const { fileTreeReadOnly } = useFileTreeData()
|
||||||
const { setContextMenuCoords } = useFileTreeMainContext()
|
const { setContextMenuCoords, setContextMenuEntityId } =
|
||||||
|
useFileTreeMainContext()
|
||||||
const { isRenaming } = useFileTreeActionable()
|
const { isRenaming } = useFileTreeActionable()
|
||||||
const { selectedEntityIds } = useFileTreeSelectable()
|
const { selectedEntityIds } = useFileTreeSelectable()
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ function FileTreeItemInner({
|
|||||||
|
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
|
|
||||||
|
setContextMenuEntityId(id)
|
||||||
setContextMenuCoords({
|
setContextMenuCoords({
|
||||||
top: ev.pageY,
|
top: ev.pageY,
|
||||||
left: ev.pageX,
|
left: ev.pageX,
|
||||||
|
|||||||
+93
-5
@@ -2,12 +2,31 @@ import { useCallback } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import * as eventTracking from '../../../../infrastructure/event-tracking'
|
import * as eventTracking from '../../../../infrastructure/event-tracking'
|
||||||
import { useProjectContext } from '@/shared/context/project-context'
|
import { useProjectContext } from '@/shared/context/project-context'
|
||||||
|
import { postJSON } from '@/infrastructure/fetch-json'
|
||||||
|
import type { ProjectCompiler } from '@ol-types/project-settings'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownDivider,
|
DropdownDivider,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
} from '@/shared/components/dropdown/dropdown-menu'
|
} from '@/shared/components/dropdown/dropdown-menu'
|
||||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||||
|
import { useFileTreeSelectable } from '../../contexts/file-tree-selectable'
|
||||||
|
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||||
|
import { useFileTreeMainContext } from '../../contexts/file-tree-main'
|
||||||
|
import { findInTree } from '../../util/find-in-tree'
|
||||||
|
import useConvertDoc from '@/features/ide-react/hooks/use-convert-doc'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
import { isValidTeXFile } from '@/main/is-valid-tex-file'
|
||||||
|
|
||||||
|
const COMPILER_BY_EXT: Record<string, ProjectCompiler> = {
|
||||||
|
tex: 'pdflatex',
|
||||||
|
rtex: 'pdflatex',
|
||||||
|
ltx: 'pdflatex',
|
||||||
|
rnw: 'pdflatex',
|
||||||
|
typ: 'typst',
|
||||||
|
qmd: 'quarto',
|
||||||
|
rmd: 'quarto',
|
||||||
|
}
|
||||||
|
|
||||||
function FileTreeItemMenuItems() {
|
function FileTreeItemMenuItems() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -23,12 +42,62 @@ function FileTreeItemMenuItems() {
|
|||||||
startUploadingDocOrFile,
|
startUploadingDocOrFile,
|
||||||
downloadPath,
|
downloadPath,
|
||||||
selectedFileName,
|
selectedFileName,
|
||||||
canSetRootDocId,
|
|
||||||
setRootDocId,
|
|
||||||
} = useFileTreeActionable()
|
} = useFileTreeActionable()
|
||||||
|
|
||||||
const { project } = useProjectContext()
|
const { project, projectId, updateProject } = useProjectContext()
|
||||||
const projectOwner = project?.owner?._id
|
const projectOwner = project?.owner?._id
|
||||||
|
const rootDocId = project?.rootDocId
|
||||||
|
|
||||||
|
const { fileTreeData, fileTreeReadOnly } = useFileTreeData()
|
||||||
|
const { selectedEntityIds } = useFileTreeSelectable()
|
||||||
|
const { contextMenuEntityId } = useFileTreeMainContext()
|
||||||
|
const selectedEntityId =
|
||||||
|
selectedEntityIds.size === 1 ? Array.from(selectedEntityIds)[0] : null
|
||||||
|
|
||||||
|
// Use context-menu-target entity for convert/set-as-main; falls back to selection
|
||||||
|
const convertEntityId = contextMenuEntityId ?? selectedEntityId
|
||||||
|
const convertEntity = convertEntityId
|
||||||
|
? findInTree(fileTreeData, convertEntityId)
|
||||||
|
: null
|
||||||
|
const isConvertableDoc = convertEntity?.type === 'doc'
|
||||||
|
const convertEntityName = convertEntity?.entity.name ?? null
|
||||||
|
|
||||||
|
const enablePandocConversions =
|
||||||
|
getMeta('ol-ExposedSettings')?.enablePandocConversions
|
||||||
|
|
||||||
|
const canConvertToTypst =
|
||||||
|
enablePandocConversions &&
|
||||||
|
!fileTreeReadOnly &&
|
||||||
|
isConvertableDoc &&
|
||||||
|
convertEntityName?.endsWith('.tex')
|
||||||
|
const canConvertToLatex =
|
||||||
|
enablePandocConversions &&
|
||||||
|
!fileTreeReadOnly &&
|
||||||
|
isConvertableDoc &&
|
||||||
|
convertEntityName?.endsWith('.typ')
|
||||||
|
|
||||||
|
const canShowSetAsMain =
|
||||||
|
!fileTreeReadOnly &&
|
||||||
|
isConvertableDoc &&
|
||||||
|
!!convertEntityId &&
|
||||||
|
convertEntityId !== rootDocId &&
|
||||||
|
!!convertEntityName &&
|
||||||
|
isValidTeXFile(convertEntityName)
|
||||||
|
|
||||||
|
const handleSetAsMain = useCallback(async () => {
|
||||||
|
if (!convertEntityId || !convertEntityName) return
|
||||||
|
const ext = convertEntityName.split('.').pop()?.toLowerCase() ?? ''
|
||||||
|
const newCompiler = COMPILER_BY_EXT[ext]
|
||||||
|
const body: Record<string, string> = { rootDocId: convertEntityId }
|
||||||
|
if (newCompiler && newCompiler !== project?.compiler) body.compiler = newCompiler
|
||||||
|
await postJSON(`/project/${projectId}/settings`, { body })
|
||||||
|
const update: Record<string, string> = { rootDocId: convertEntityId }
|
||||||
|
if (newCompiler) update.compiler = newCompiler
|
||||||
|
updateProject(update)
|
||||||
|
}, [convertEntityId, convertEntityName, project, projectId, updateProject])
|
||||||
|
|
||||||
|
const { convert: convertToTypst } = useConvertDoc('typst', convertEntityId)
|
||||||
|
const { convert: convertToLatex } = useConvertDoc('latex', convertEntityId)
|
||||||
|
|
||||||
const downloadWithAnalytics = useCallback(() => {
|
const downloadWithAnalytics = useCallback(() => {
|
||||||
// we are only interested in downloads of bib files WRT analytics, for the purposes of promoting the tpr integrations
|
// we are only interested in downloads of bib files WRT analytics, for the purposes of promoting the tpr integrations
|
||||||
@@ -65,16 +134,35 @@ function FileTreeItemMenuItems() {
|
|||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</li>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
{canSetRootDocId ? (
|
{canShowSetAsMain ? (
|
||||||
<>
|
<>
|
||||||
<DropdownDivider />
|
<DropdownDivider />
|
||||||
<li role="none">
|
<li role="none">
|
||||||
<DropdownItem onClick={setRootDocId}>
|
<DropdownItem onClick={handleSetAsMain}>
|
||||||
{t('set_as_main_document')}
|
{t('set_as_main_document')}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</li>
|
</li>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{(canConvertToTypst || canConvertToLatex) ? (
|
||||||
|
<>
|
||||||
|
<DropdownDivider />
|
||||||
|
{canConvertToTypst && (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem onClick={convertToTypst}>
|
||||||
|
{t('convert_to_typst')}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{canConvertToLatex && (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem onClick={convertToLatex}>
|
||||||
|
{t('convert_to_latex')}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{canDelete ? (
|
{canDelete ? (
|
||||||
<>
|
<>
|
||||||
<DropdownDivider />
|
<DropdownDivider />
|
||||||
|
|||||||
+3
-1
@@ -5,7 +5,8 @@ import MaterialIcon from '@/shared/components/material-icon'
|
|||||||
|
|
||||||
function FileTreeItemMenu({ id, name }: { id: string; name: string }) {
|
function FileTreeItemMenu({ id, name }: { id: string; name: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
|
const { contextMenuCoords, setContextMenuCoords, setContextMenuEntityId } =
|
||||||
|
useFileTreeMainContext()
|
||||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
const isMenuOpen = Boolean(contextMenuCoords)
|
const isMenuOpen = Boolean(contextMenuCoords)
|
||||||
@@ -13,6 +14,7 @@ function FileTreeItemMenu({ id, name }: { id: string; name: string }) {
|
|||||||
function handleClick(event: React.MouseEvent) {
|
function handleClick(event: React.MouseEvent) {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!contextMenuCoords && menuButtonRef.current) {
|
if (!contextMenuCoords && menuButtonRef.current) {
|
||||||
|
setContextMenuEntityId(id)
|
||||||
const target = menuButtonRef.current.getBoundingClientRect()
|
const target = menuButtonRef.current.getBoundingClientRect()
|
||||||
setContextMenuCoords({
|
setContextMenuCoords({
|
||||||
top: target.top + target.height / 2,
|
top: target.top + target.height / 2,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const FileTreeMainContext = createContext<
|
|||||||
setStartedFreeTrial: (value: boolean) => void
|
setStartedFreeTrial: (value: boolean) => void
|
||||||
contextMenuCoords: ContextMenuCoords | null
|
contextMenuCoords: ContextMenuCoords | null
|
||||||
setContextMenuCoords: (value: ContextMenuCoords | null) => void
|
setContextMenuCoords: (value: ContextMenuCoords | null) => void
|
||||||
|
contextMenuEntityId: string | null
|
||||||
|
setContextMenuEntityId: (value: string | null) => void
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
>(undefined)
|
>(undefined)
|
||||||
@@ -39,6 +41,9 @@ export const FileTreeMainProvider: FC<
|
|||||||
}) => {
|
}) => {
|
||||||
const [contextMenuCoords, setContextMenuCoords] =
|
const [contextMenuCoords, setContextMenuCoords] =
|
||||||
useState<ContextMenuCoords | null>(null)
|
useState<ContextMenuCoords | null>(null)
|
||||||
|
const [contextMenuEntityId, setContextMenuEntityId] = useState<string | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileTreeMainContext.Provider
|
<FileTreeMainContext.Provider
|
||||||
@@ -48,6 +53,8 @@ export const FileTreeMainProvider: FC<
|
|||||||
setStartedFreeTrial,
|
setStartedFreeTrial,
|
||||||
contextMenuCoords,
|
contextMenuCoords,
|
||||||
setContextMenuCoords,
|
setContextMenuCoords,
|
||||||
|
contextMenuEntityId,
|
||||||
|
setContextMenuEntityId,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Panel, PanelGroup } from 'react-resizable-panels'
|
import { Panel, PanelGroup } from 'react-resizable-panels'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
|
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
|
||||||
|
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
|
||||||
import PdfPreview from '@/features/pdf-preview/components/pdf-preview'
|
import PdfPreview from '@/features/pdf-preview/components/pdf-preview'
|
||||||
import { RailLayout } from '../rail/rail'
|
import { RailLayout } from '../rail/rail'
|
||||||
import { Toolbar } from '../toolbar/toolbar'
|
import { Toolbar } from '../toolbar/toolbar'
|
||||||
@@ -8,7 +9,7 @@ import { HorizontalToggler } from '@/features/ide-react/components/resize/horizo
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { usePdfPane } from '@/features/ide-react/hooks/use-pdf-pane'
|
import { usePdfPane } from '@/features/ide-react/hooks/use-pdf-pane'
|
||||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||||
import { ElementType, useState } from 'react'
|
import { ElementType, useEffect, useState } from 'react'
|
||||||
import EditorPanel from '../editor/editor-panel'
|
import EditorPanel from '../editor/editor-panel'
|
||||||
import { useRailContext } from '../../context/rail-context'
|
import { useRailContext } from '../../context/rail-context'
|
||||||
import HistoryContainer from '@/features/ide-react/components/history-container'
|
import HistoryContainer from '@/features/ide-react/components/history-container'
|
||||||
@@ -25,6 +26,19 @@ const mainEditorLayoutModalsModules: Array<{
|
|||||||
path: string
|
path: string
|
||||||
}> = importOverleafModules('mainEditorLayoutModals')
|
}> = importOverleafModules('mainEditorLayoutModals')
|
||||||
|
|
||||||
|
// Bootstrap md breakpoint — below this we stack panels vertically on mobile
|
||||||
|
const MOBILE_MQ = '(max-width: 767px)'
|
||||||
|
// Secondary check: browsers that spoof viewport width (e.g. Tor Browser) still
|
||||||
|
// expose `pointer: coarse` for real touch hardware.
|
||||||
|
const TOUCH_MQ = '(pointer: coarse) and (max-width: 1024px)'
|
||||||
|
|
||||||
|
function detectMobile() {
|
||||||
|
return (
|
||||||
|
window.matchMedia(MOBILE_MQ).matches ||
|
||||||
|
window.matchMedia(TOUCH_MQ).matches
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
const [resizing, setResizing] = useState(false)
|
const [resizing, setResizing] = useState(false)
|
||||||
const { resizing: railResizing } = useRailContext()
|
const { resizing: railResizing } = useRailContext()
|
||||||
@@ -38,8 +52,29 @@ export default function MainLayout() {
|
|||||||
} = usePdfPane()
|
} = usePdfPane()
|
||||||
const { view, pdfLayout } = useLayoutContext()
|
const { view, pdfLayout } = useLayoutContext()
|
||||||
|
|
||||||
|
const [isMobile, setIsMobile] = useState(detectMobile)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setIsMobile(detectMobile())
|
||||||
|
const mq1 = window.matchMedia(MOBILE_MQ)
|
||||||
|
const mq2 = window.matchMedia(TOUCH_MQ)
|
||||||
|
mq1.addEventListener('change', handler)
|
||||||
|
mq2.addEventListener('change', handler)
|
||||||
|
return () => {
|
||||||
|
mq1.removeEventListener('change', handler)
|
||||||
|
mq2.removeEventListener('change', handler)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// verticalSplit is always vertical; sideBySide becomes vertical on mobile
|
||||||
|
const isVertical =
|
||||||
|
pdfLayout === 'verticalSplit' ||
|
||||||
|
(pdfLayout === 'sideBySide' && isMobile)
|
||||||
|
|
||||||
const editorIsOpen =
|
const editorIsOpen =
|
||||||
view === 'editor' || view === 'file' || pdfLayout === 'sideBySide'
|
view === 'editor' ||
|
||||||
|
view === 'file' ||
|
||||||
|
pdfLayout === 'sideBySide' ||
|
||||||
|
pdfLayout === 'verticalSplit'
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -58,8 +93,20 @@ export default function MainLayout() {
|
|||||||
<Panel id="ide-redesign-editor-and-pdf-panel" order={2}>
|
<Panel id="ide-redesign-editor-and-pdf-panel" order={2}>
|
||||||
<HistoryContainer />
|
<HistoryContainer />
|
||||||
<PanelGroup
|
<PanelGroup
|
||||||
autoSaveId="ide-redesign-editor-and-pdf-panel-group"
|
key={isVertical ? 'vertical' : 'horizontal'}
|
||||||
direction="horizontal"
|
autoSaveId={
|
||||||
|
isVertical
|
||||||
|
? // On mobile, skip autoSave: a stale collapsed PDF pane
|
||||||
|
// would fire onCollapse → changeLayout('flat'), overriding
|
||||||
|
// the verticalSplit default from getInitialLayout().
|
||||||
|
// The pdf.layout localStorage key already persists the
|
||||||
|
// user's explicit flat/open preference independently.
|
||||||
|
isMobile
|
||||||
|
? null
|
||||||
|
: 'ide-redesign-editor-and-pdf-panel-group-vertical'
|
||||||
|
: 'ide-redesign-editor-and-pdf-panel-group'
|
||||||
|
}
|
||||||
|
direction={isVertical ? 'vertical' : 'horizontal'}
|
||||||
className={classNames({
|
className={classNames({
|
||||||
hidden: view === 'history',
|
hidden: view === 'history',
|
||||||
})}
|
})}
|
||||||
@@ -79,29 +126,38 @@ export default function MainLayout() {
|
|||||||
<EditorPanel />
|
<EditorPanel />
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
<HorizontalResizeHandle
|
{isVertical ? (
|
||||||
resizable={pdfLayout === 'sideBySide'}
|
<VerticalResizeHandle
|
||||||
onDragging={setResizing}
|
onDragging={setResizing}
|
||||||
onDoubleClick={togglePdfPane}
|
className={classNames({
|
||||||
hitAreaMargins={{ coarse: 0, fine: 0 }}
|
hidden: !editorIsOpen,
|
||||||
className={classNames({
|
})}
|
||||||
hidden: !editorIsOpen,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<HorizontalToggler
|
|
||||||
id="ide-redesign-pdf-panel"
|
|
||||||
togglerType="east"
|
|
||||||
isOpen={isPdfOpen}
|
|
||||||
setIsOpen={setIsPdfOpen}
|
|
||||||
tooltipWhenOpen={t('tooltip_hide_pdf')}
|
|
||||||
tooltipWhenClosed={t('tooltip_show_pdf')}
|
|
||||||
/>
|
/>
|
||||||
{pdfLayout === 'sideBySide' && (
|
) : (
|
||||||
<div className="synctex-controls">
|
<HorizontalResizeHandle
|
||||||
<DefaultSynctexControl />
|
resizable={pdfLayout === 'sideBySide'}
|
||||||
</div>
|
onDragging={setResizing}
|
||||||
)}
|
onDoubleClick={togglePdfPane}
|
||||||
</HorizontalResizeHandle>
|
hitAreaMargins={{ coarse: 0, fine: 0 }}
|
||||||
|
className={classNames({
|
||||||
|
hidden: !editorIsOpen,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<HorizontalToggler
|
||||||
|
id="ide-redesign-pdf-panel"
|
||||||
|
togglerType="east"
|
||||||
|
isOpen={isPdfOpen}
|
||||||
|
setIsOpen={setIsPdfOpen}
|
||||||
|
tooltipWhenOpen={t('tooltip_hide_pdf')}
|
||||||
|
tooltipWhenClosed={t('tooltip_show_pdf')}
|
||||||
|
/>
|
||||||
|
{pdfLayout === 'sideBySide' && (
|
||||||
|
<div className="synctex-controls">
|
||||||
|
<DefaultSynctexControl />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</HorizontalResizeHandle>
|
||||||
|
)}
|
||||||
<Panel
|
<Panel
|
||||||
collapsible
|
collapsible
|
||||||
className={classNames('ide-redesign-pdf-container', {
|
className={classNames('ide-redesign-pdf-container', {
|
||||||
|
|||||||
+24
-1
@@ -15,7 +15,12 @@ import { isMac } from '@/shared/utils/os'
|
|||||||
import { Shortcut } from '@/shared/components/shortcut'
|
import { Shortcut } from '@/shared/components/shortcut'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
|
||||||
type LayoutOption = 'sideBySide' | 'editorOnly' | 'pdfOnly' | 'detachedPdf'
|
type LayoutOption =
|
||||||
|
| 'sideBySide'
|
||||||
|
| 'verticalSplit'
|
||||||
|
| 'editorOnly'
|
||||||
|
| 'pdfOnly'
|
||||||
|
| 'detachedPdf'
|
||||||
|
|
||||||
const getActiveLayoutOption = ({
|
const getActiveLayoutOption = ({
|
||||||
pdfLayout,
|
pdfLayout,
|
||||||
@@ -46,6 +51,10 @@ const getActiveLayoutOption = ({
|
|||||||
return 'sideBySide'
|
return 'sideBySide'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pdfLayout === 'verticalSplit') {
|
||||||
|
return 'verticalSplit'
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,12 +101,14 @@ const shortcuts: Record<LayoutOption, string[] | null> = isMac
|
|||||||
editorOnly: ['⌃', '⌘', '←'],
|
editorOnly: ['⌃', '⌘', '←'],
|
||||||
pdfOnly: ['⌃', '⌘', '→'],
|
pdfOnly: ['⌃', '⌘', '→'],
|
||||||
sideBySide: ['⌃', '⌘', '↓'],
|
sideBySide: ['⌃', '⌘', '↓'],
|
||||||
|
verticalSplit: null,
|
||||||
detachedPdf: ['⌃', '⌘', '↑'],
|
detachedPdf: ['⌃', '⌘', '↑'],
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
editorOnly: null,
|
editorOnly: null,
|
||||||
pdfOnly: null,
|
pdfOnly: null,
|
||||||
sideBySide: null,
|
sideBySide: null,
|
||||||
|
verticalSplit: null,
|
||||||
detachedPdf: null,
|
detachedPdf: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +147,18 @@ export default function ChangeLayoutOptions() {
|
|||||||
>
|
>
|
||||||
{t('split_view')}
|
{t('split_view')}
|
||||||
</LayoutDropdownItem>
|
</LayoutDropdownItem>
|
||||||
|
<LayoutDropdownItem
|
||||||
|
onClick={() => handleChangeLayout('verticalSplit')}
|
||||||
|
active={activeLayoutOption === 'verticalSplit'}
|
||||||
|
leadingIcon="horizontal_split"
|
||||||
|
trailingIcon={
|
||||||
|
shortcuts.verticalSplit && (
|
||||||
|
<Shortcut keys={shortcuts.verticalSplit} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('top_bottom_split_view')}
|
||||||
|
</LayoutDropdownItem>
|
||||||
<LayoutDropdownItem
|
<LayoutDropdownItem
|
||||||
onClick={() => handleChangeLayout('flat', 'editor')}
|
onClick={() => handleChangeLayout('flat', 'editor')}
|
||||||
active={activeLayoutOption === 'editorOnly'}
|
active={activeLayoutOption === 'editorOnly'}
|
||||||
|
|||||||
+6
-1
@@ -42,6 +42,7 @@ const ExportDocumentErrorToast = ({ data }: { data?: any }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ExportDocumentSuccessToast = ({ data }: { data?: any }) => {
|
const ExportDocumentSuccessToast = ({ data }: { data?: any }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const type = data?.type
|
const type = data?.type
|
||||||
if (type === 'docx') {
|
if (type === 'docx') {
|
||||||
return (
|
return (
|
||||||
@@ -85,6 +86,10 @@ const ExportDocumentSuccessToast = ({ data }: { data?: any }) => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
} else if (type === 'typst') {
|
||||||
|
return <span>{t('typst_export_feedback_message')}</span>
|
||||||
|
} else if (type === 'latex') {
|
||||||
|
return <span>{t('latex_export_feedback_message')}</span>
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<Trans
|
<Trans
|
||||||
@@ -175,7 +180,7 @@ export const hidePreparingExportToast = (handle: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const showExportDocumentSuccess = (
|
export const showExportDocumentSuccess = (
|
||||||
type: 'docx' | 'markdown' | 'html'
|
type: 'docx' | 'markdown' | 'html' | 'typst' | 'latex'
|
||||||
) => {
|
) => {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('ide:show-toast', {
|
new CustomEvent('ide:show-toast', {
|
||||||
|
|||||||
+11
-2
@@ -6,10 +6,11 @@ import { useCommandProvider } from '../../hooks/use-command-provider'
|
|||||||
import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
|
import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
|
||||||
import { useRootDoc } from '@/shared/hooks/use-root-doc'
|
import { useRootDoc } from '@/shared/hooks/use-root-doc'
|
||||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||||
|
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
|
||||||
|
|
||||||
type ExportProjectWithConversionProps = {
|
type ExportProjectWithConversionProps = {
|
||||||
featureFlag?: string
|
featureFlag?: string
|
||||||
conversionType: 'docx' | 'markdown' | 'html'
|
conversionType: 'docx' | 'markdown' | 'html' | 'typst' | 'latex'
|
||||||
label: string
|
label: string
|
||||||
menuBarId: string
|
menuBarId: string
|
||||||
}
|
}
|
||||||
@@ -22,6 +23,11 @@ export const ExportProjectWithConversionButton: FC<
|
|||||||
const enablePandocConversions =
|
const enablePandocConversions =
|
||||||
getMeta('ol-ExposedSettings')?.enablePandocConversions
|
getMeta('ol-ExposedSettings')?.enablePandocConversions
|
||||||
const anonymous = getMeta('ol-anonymous')
|
const anonymous = getMeta('ol-anonymous')
|
||||||
|
const { compiler } = useProjectSettingsContext()
|
||||||
|
const isLatexProject = !['typst', 'quarto'].includes(compiler ?? '')
|
||||||
|
// latex export is for converting Typst projects to LaTeX; all others are for LaTeX projects
|
||||||
|
const isProjectTypeMatch =
|
||||||
|
conversionType === 'latex' ? compiler === 'typst' : isLatexProject
|
||||||
const getRootDocInfo = useRootDoc()
|
const getRootDocInfo = useRootDoc()
|
||||||
const { openDocs } = useEditorManagerContext()
|
const { openDocs } = useEditorManagerContext()
|
||||||
const downloadConversion = useConvertProject(
|
const downloadConversion = useConvertProject(
|
||||||
@@ -31,7 +37,10 @@ export const ExportProjectWithConversionButton: FC<
|
|||||||
)
|
)
|
||||||
|
|
||||||
const showExportButton =
|
const showExportButton =
|
||||||
splitTestEnabledIfNeeded && enablePandocConversions && !anonymous
|
splitTestEnabledIfNeeded &&
|
||||||
|
enablePandocConversions &&
|
||||||
|
!anonymous &&
|
||||||
|
isProjectTypeMatch
|
||||||
|
|
||||||
useCommandProvider(
|
useCommandProvider(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ export const ToolbarMenuBar = () => {
|
|||||||
'export-as-docx',
|
'export-as-docx',
|
||||||
'export-as-markdown',
|
'export-as-markdown',
|
||||||
'export-as-html',
|
'export-as-html',
|
||||||
|
'export-as-typst',
|
||||||
|
'export-as-latex',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -243,7 +245,7 @@ export const ToolbarMenuBar = () => {
|
|||||||
>
|
>
|
||||||
<ChangeLayoutOptions />
|
<ChangeLayoutOptions />
|
||||||
<DropdownDivider />
|
<DropdownDivider />
|
||||||
<DropdownHeader>Editor settings</DropdownHeader>
|
<DropdownHeader>{t('editor_settings')}</DropdownHeader>
|
||||||
<MenuBarOption
|
<MenuBarOption
|
||||||
eventKey="show_breadcrumbs"
|
eventKey="show_breadcrumbs"
|
||||||
title={t('show_breadcrumbs')}
|
title={t('show_breadcrumbs')}
|
||||||
|
|||||||
@@ -95,6 +95,16 @@ export const ToolbarProjectTitle = () => {
|
|||||||
label={t('export_as_html')}
|
label={t('export_as_html')}
|
||||||
menuBarId="export-as-html"
|
menuBarId="export-as-html"
|
||||||
/>
|
/>
|
||||||
|
<ExportProjectWithConversionButton
|
||||||
|
conversionType="typst"
|
||||||
|
label={t('export_as_typst')}
|
||||||
|
menuBarId="export-as-typst"
|
||||||
|
/>
|
||||||
|
<ExportProjectWithConversionButton
|
||||||
|
conversionType="latex"
|
||||||
|
label={t('export_as_latex')}
|
||||||
|
menuBarId="export-as-latex"
|
||||||
|
/>
|
||||||
<DropdownDivider />
|
<DropdownDivider />
|
||||||
<DuplicateProject />
|
<DuplicateProject />
|
||||||
<OLDropdownMenuItem
|
<OLDropdownMenuItem
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { postJSON } from '@/infrastructure/fetch-json'
|
||||||
|
import { useProjectContext } from '@/shared/context/project-context'
|
||||||
|
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import {
|
||||||
|
showExportDocumentError,
|
||||||
|
showExportDocumentSuccess,
|
||||||
|
hideExportDocumentError,
|
||||||
|
} from '../components/toolbar/export-document-toasts'
|
||||||
|
|
||||||
|
export default function useConvertDoc(
|
||||||
|
type: 'typst' | 'latex',
|
||||||
|
docId: string | null
|
||||||
|
) {
|
||||||
|
const { projectId } = useProjectContext()
|
||||||
|
const { dispatchCreateDoc } = useFileTreeData()
|
||||||
|
const [converting, setConverting] = useState(false)
|
||||||
|
|
||||||
|
const convert = useCallback(async () => {
|
||||||
|
if (!docId) return
|
||||||
|
setConverting(true)
|
||||||
|
hideExportDocumentError()
|
||||||
|
try {
|
||||||
|
const result = await postJSON(
|
||||||
|
`/project/${projectId}/doc/${docId}/convert/${type}`,
|
||||||
|
{ body: {} }
|
||||||
|
)
|
||||||
|
showExportDocumentSuccess(type)
|
||||||
|
if (result.isNew) {
|
||||||
|
dispatchCreateDoc(result.parentFolderId, {
|
||||||
|
_id: result.docId,
|
||||||
|
name: result.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage = err?.data?.error
|
||||||
|
showExportDocumentError(errorMessage)
|
||||||
|
} finally {
|
||||||
|
setConverting(false)
|
||||||
|
}
|
||||||
|
}, [projectId, docId, type, dispatchCreateDoc])
|
||||||
|
|
||||||
|
return { convert, converting }
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import { OpenDocuments } from '../editor/open-documents'
|
|||||||
const SLOW_CONVERSION_THRESHOLD = 2000
|
const SLOW_CONVERSION_THRESHOLD = 2000
|
||||||
|
|
||||||
export default function useConvertProject(
|
export default function useConvertProject(
|
||||||
type: 'docx' | 'markdown' | 'html',
|
type: 'docx' | 'markdown' | 'html' | 'typst' | 'latex',
|
||||||
openDocs: OpenDocuments,
|
openDocs: OpenDocuments,
|
||||||
getRootDocInfo: () => RootDocInfo
|
getRootDocInfo: () => RootDocInfo
|
||||||
) {
|
) {
|
||||||
@@ -45,7 +45,7 @@ export default function useConvertProject(
|
|||||||
if (downloadUrl) {
|
if (downloadUrl) {
|
||||||
const url = new URL(downloadUrl, window.location.origin)
|
const url = new URL(downloadUrl, window.location.origin)
|
||||||
location.assign(url.toString())
|
location.assign(url.toString())
|
||||||
showExportDocumentSuccess(type)
|
showExportDocumentSuccess(type as 'docx' | 'markdown' | 'html' | 'typst' | 'latex')
|
||||||
} else {
|
} else {
|
||||||
showExportDocumentError()
|
showExportDocumentError()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export const usePdfPane = () => {
|
|||||||
useLayoutContext()
|
useLayoutContext()
|
||||||
|
|
||||||
const pdfPanelRef = useRef<ImperativePanelHandle>(null)
|
const pdfPanelRef = useRef<ImperativePanelHandle>(null)
|
||||||
const pdfIsOpen = pdfLayout === 'sideBySide' || view === 'pdf'
|
const pdfIsOpen =
|
||||||
|
pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit' || view === 'pdf'
|
||||||
|
|
||||||
useCollapsiblePanel(pdfIsOpen, pdfPanelRef)
|
useCollapsiblePanel(pdfIsOpen, pdfPanelRef)
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ export const usePdfPane = () => {
|
|||||||
|
|
||||||
// triggered when the PDF pane becomes closed (either by dragging or toggling)
|
// triggered when the PDF pane becomes closed (either by dragging or toggling)
|
||||||
const handlePdfPaneCollapse = useCallback(() => {
|
const handlePdfPaneCollapse = useCallback(() => {
|
||||||
if (pdfLayout === 'sideBySide') {
|
if (pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit') {
|
||||||
changeLayout('flat', 'editor')
|
changeLayout('flat', 'editor')
|
||||||
}
|
}
|
||||||
}, [changeLayout, pdfLayout])
|
}, [changeLayout, pdfLayout])
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ function SwitchToEditorButton() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pdfLayout === 'sideBySide') {
|
if (pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+76
-26
@@ -15,6 +15,7 @@ import LeaveProjectButton from '../table/cells/action-buttons/leave-project-butt
|
|||||||
import DeleteProjectButton from '../table/cells/action-buttons/delete-project-button'
|
import DeleteProjectButton from '../table/cells/action-buttons/delete-project-button'
|
||||||
import { Project } from '../../../../../../types/project/dashboard/api'
|
import { Project } from '../../../../../../types/project/dashboard/api'
|
||||||
import CompileAndDownloadProjectPDFButton from '../table/cells/action-buttons/compile-and-download-project-pdf-button'
|
import CompileAndDownloadProjectPDFButton from '../table/cells/action-buttons/compile-and-download-project-pdf-button'
|
||||||
|
import DownloadPresentationButton from '../table/cells/action-buttons/download-presentation-button'
|
||||||
import RenameProjectButton from '../table/cells/action-buttons/rename-project-button'
|
import RenameProjectButton from '../table/cells/action-buttons/rename-project-button'
|
||||||
import MaterialIcon from '@/shared/components/material-icon'
|
import MaterialIcon from '@/shared/components/material-icon'
|
||||||
import OLSpinner from '@/shared/components/ol/ol-spinner'
|
import OLSpinner from '@/shared/components/ol/ol-spinner'
|
||||||
@@ -77,32 +78,81 @@ function ActionsDropdown({ project }: ActionDropdownProps) {
|
|||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</DownloadProjectButton>
|
</DownloadProjectButton>
|
||||||
<CompileAndDownloadProjectPDFButton project={project}>
|
{project.compiler === 'quarto' ? (
|
||||||
{(text, pendingCompile, downloadProject) => (
|
<DownloadPresentationButton project={project}>
|
||||||
<li role="none">
|
{(startExport, exporting) => (
|
||||||
<DropdownItem
|
<>
|
||||||
as="button"
|
<li role="none">
|
||||||
tabIndex={-1}
|
<DropdownItem
|
||||||
onClick={e => {
|
as="button"
|
||||||
e.stopPropagation()
|
tabIndex={-1}
|
||||||
downloadProject()
|
onClick={() => startExport('html')}
|
||||||
}}
|
disabled={exporting !== null}
|
||||||
leadingIcon={
|
leadingIcon={
|
||||||
pendingCompile ? (
|
exporting === 'html' ? (
|
||||||
<OLSpinner
|
<OLSpinner
|
||||||
size="sm"
|
size="sm"
|
||||||
className="dropdown-item-leading-icon spinner"
|
className="dropdown-item-leading-icon spinner"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
'picture_as_pdf'
|
'download'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{text}
|
{t('download_as_standalone_html')}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</li>
|
</li>
|
||||||
)}
|
<li role="none">
|
||||||
</CompileAndDownloadProjectPDFButton>
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={() => startExport('pdf')}
|
||||||
|
disabled={exporting !== null}
|
||||||
|
leadingIcon={
|
||||||
|
exporting === 'pdf' ? (
|
||||||
|
<OLSpinner
|
||||||
|
size="sm"
|
||||||
|
className="dropdown-item-leading-icon spinner"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'picture_as_pdf'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('download_as_pdf_slides')}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DownloadPresentationButton>
|
||||||
|
) : (
|
||||||
|
<CompileAndDownloadProjectPDFButton project={project}>
|
||||||
|
{(text, pendingCompile, downloadProject) => (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
downloadProject()
|
||||||
|
}}
|
||||||
|
leadingIcon={
|
||||||
|
pendingCompile ? (
|
||||||
|
<OLSpinner
|
||||||
|
size="sm"
|
||||||
|
className="dropdown-item-leading-icon spinner"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'picture_as_pdf'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</CompileAndDownloadProjectPDFButton>
|
||||||
|
)}
|
||||||
<ArchiveProjectButton project={project}>
|
<ArchiveProjectButton project={project}>
|
||||||
{(text, handleOpenModal) => (
|
{(text, handleOpenModal) => (
|
||||||
<li role="none">
|
<li role="none">
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function NewProjectButton({
|
|||||||
const markdownImportEnabled =
|
const markdownImportEnabled =
|
||||||
useFeatureFlag('import-markdown') &&
|
useFeatureFlag('import-markdown') &&
|
||||||
getMeta('ol-ExposedSettings').enablePandocConversions
|
getMeta('ol-ExposedSettings').enablePandocConversions
|
||||||
const { selectedTagId, tags } = useProjectListContext()
|
const { selectedTagId, tags } = useProjectListContext()
|
||||||
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
|
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
|
||||||
const initialTags =
|
const initialTags =
|
||||||
isLibraryEnabled && selectedTagId
|
isLibraryEnabled && selectedTagId
|
||||||
|
|||||||
+7
-1
@@ -19,7 +19,7 @@ function ImportDocumentModal({
|
|||||||
onHide,
|
onHide,
|
||||||
openProject,
|
openProject,
|
||||||
}: {
|
}: {
|
||||||
type: 'docx' | 'markdown'
|
type: 'docx' | 'markdown' | 'typst'
|
||||||
onHide: () => void
|
onHide: () => void
|
||||||
openProject: (id: string, convertedFrom?: string) => void
|
openProject: (id: string, convertedFrom?: string) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -39,6 +39,12 @@ function ImportDocumentModal({
|
|||||||
browseLabel: 'Select .md file',
|
browseLabel: 'Select .md file',
|
||||||
dragLabel: '%{browseFiles} or \n\n Drag .md file',
|
dragLabel: '%{browseFiles} or \n\n Drag .md file',
|
||||||
},
|
},
|
||||||
|
typst: {
|
||||||
|
allowedFileTypes: ['.typ'],
|
||||||
|
title: t('choose_typst_file'),
|
||||||
|
browseLabel: 'Select .typ file',
|
||||||
|
dragLabel: '%{browseFiles} or \n\n Drag .typ file',
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[t]
|
[t]
|
||||||
)
|
)
|
||||||
|
|||||||
+11
@@ -21,6 +21,7 @@ export type NewProjectButtonModalVariant =
|
|||||||
| 'import_from_github'
|
| 'import_from_github'
|
||||||
| 'import_docx'
|
| 'import_docx'
|
||||||
| 'import_markdown'
|
| 'import_markdown'
|
||||||
|
| 'import_typst'
|
||||||
|
|
||||||
type NewProjectButtonModalProps = {
|
type NewProjectButtonModalProps = {
|
||||||
modal: Nullable<NewProjectButtonModalVariant>
|
modal: Nullable<NewProjectButtonModalVariant>
|
||||||
@@ -128,6 +129,16 @@ function NewProjectButtonModal({
|
|||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
|
case 'import_typst':
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
|
||||||
|
<ImportDocumentModal
|
||||||
|
type="typst"
|
||||||
|
onHide={onHide}
|
||||||
|
openProject={openProject}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
case 'import_from_github':
|
case 'import_from_github':
|
||||||
return <ImportProjectFromGithubModalWrapper onHide={onHide} />
|
return <ImportProjectFromGithubModalWrapper onHide={onHide} />
|
||||||
default:
|
default:
|
||||||
|
|||||||
+5
-4
@@ -128,20 +128,21 @@ function BannerContent({
|
|||||||
}: {
|
}: {
|
||||||
variant: GroupsAndEnterpriseBannerVariant
|
variant: GroupsAndEnterpriseBannerVariant
|
||||||
}) {
|
}) {
|
||||||
|
const { appName } = getMeta('ol-ExposedSettings')
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case 'on-premise':
|
case 'on-premise':
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
Overleaf On-Premises: Does your company want to keep its data within
|
{appName} On-Premises: Does your company want to keep its data within
|
||||||
its firewall? Overleaf offers Server Pro, an on-premises solution for
|
its firewall? {appName} offers Server Pro, an on-premises solution for
|
||||||
companies. Get in touch to learn more.
|
companies. Get in touch to learn more.
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
case 'FOMO':
|
case 'FOMO':
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
Why do Fortune 500 companies and top research institutions trust
|
Why do Fortune 500 companies and top research institutions trust{' '}
|
||||||
Overleaf to streamline their collaboration? Get in touch to learn
|
{appName} to streamline their collaboration? Get in touch to learn
|
||||||
more.
|
more.
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,37 +44,19 @@ export function ProjectListDsNav() {
|
|||||||
|
|
||||||
const tableTopArea = (
|
const tableTopArea = (
|
||||||
<div className="pt-2 pb-3 d-md-none d-flex gap-2">
|
<div className="pt-2 pb-3 d-md-none d-flex gap-2">
|
||||||
{isLibraryEnabled ? (
|
{showNewProjectButton && (
|
||||||
<>
|
<NewProjectButton
|
||||||
<SearchForm
|
id="new-project-button-projects-table"
|
||||||
inputValue={searchText}
|
showAddAffiliationWidget
|
||||||
setInputValue={setSearchText}
|
/>
|
||||||
filter={filter}
|
|
||||||
selectedTag={selectedTag}
|
|
||||||
className="overflow-hidden flex-grow-1"
|
|
||||||
/>
|
|
||||||
{showNewProjectButton && (
|
|
||||||
<NewProjectButton
|
|
||||||
id="new-project-button-projects-table"
|
|
||||||
showAddAffiliationWidget
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<NewProjectButton
|
|
||||||
id="new-project-button-projects-table"
|
|
||||||
showAddAffiliationWidget
|
|
||||||
/>
|
|
||||||
<SearchForm
|
|
||||||
inputValue={searchText}
|
|
||||||
setInputValue={setSearchText}
|
|
||||||
filter={filter}
|
|
||||||
selectedTag={selectedTag}
|
|
||||||
className="overflow-hidden flex-grow-1"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<SearchForm
|
||||||
|
inputValue={searchText}
|
||||||
|
setInputValue={setSearchText}
|
||||||
|
filter={filter}
|
||||||
|
selectedTag={selectedTag}
|
||||||
|
className="overflow-hidden flex-grow-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,9 +89,6 @@ export function ProjectListDsNav() {
|
|||||||
<ProjectTools />
|
<ProjectTools />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="d-md-none">
|
|
||||||
<CurrentPlanWidget />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="project-ds-nav-project-list">
|
<div className="project-ds-nav-project-list">
|
||||||
@@ -145,8 +124,8 @@ export function ProjectListDsNav() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
|
{tableTopArea}
|
||||||
<TableContainer bordered>
|
<TableContainer bordered>
|
||||||
{tableTopArea}
|
|
||||||
<ProjectListTable />
|
<ProjectListTable />
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,485 @@
|
|||||||
|
import React, {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
Filter,
|
||||||
|
useProjectListContext,
|
||||||
|
} from '../context/project-list-context'
|
||||||
|
import { Project } from '../../../../../types/project/dashboard/api'
|
||||||
|
import { getOwnerName } from '../util/project'
|
||||||
|
import { fromNowDate } from '../../../utils/dates'
|
||||||
|
import { ProjectCompiler } from '../../../../../types/project-settings'
|
||||||
|
import { getTagColor } from '../util/tag'
|
||||||
|
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
import DefaultNavbar from '@/shared/components/navbar/default-navbar'
|
||||||
|
import Footer from '@/shared/components/footer/footer'
|
||||||
|
import SidebarDsNav from '@/features/project-list/components/sidebar/sidebar-ds-nav'
|
||||||
|
import SystemMessages from '@/shared/components/system-messages'
|
||||||
|
import CookieBanner from '@/shared/components/cookie-banner'
|
||||||
|
import UserNotifications from './notifications/user-notifications'
|
||||||
|
import SearchForm from './search-form'
|
||||||
|
import NewProjectButton from './new-project-button'
|
||||||
|
import ProjectListTitle from './title/project-list-title'
|
||||||
|
import LoadMore from './load-more'
|
||||||
|
import DashApiError from './dash-api-error'
|
||||||
|
import { ProjectCheckbox } from './table/project-checkbox'
|
||||||
|
import ProjectTools from './table/project-tools/project-tools'
|
||||||
|
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
|
||||||
|
import { CopyProjectButtonTooltip } from './table/cells/action-buttons/copy-project-button'
|
||||||
|
import { DownloadProjectButtonTooltip } from './table/cells/action-buttons/download-project-button'
|
||||||
|
import { CompileAndDownloadProjectPDFButtonTooltip } from './table/cells/action-buttons/compile-and-download-project-pdf-button'
|
||||||
|
import { DownloadPresentationButtonTooltip } from './table/cells/action-buttons/download-presentation-button'
|
||||||
|
import { ArchiveProjectButtonTooltip } from './table/cells/action-buttons/archive-project-button'
|
||||||
|
import { TrashProjectButtonTooltip } from './table/cells/action-buttons/trash-project-button'
|
||||||
|
import FormatCell from './table/cells/format-cell'
|
||||||
|
import OwnerCell from './table/cells/owner-cell'
|
||||||
|
import LastUpdatedCell from './table/cells/last-updated-cell'
|
||||||
|
import ActionsCell from './table/cells/actions-cell'
|
||||||
|
import ActionsDropdown from './dropdown/actions-dropdown'
|
||||||
|
import InlineTags from './table/cells/inline-tags'
|
||||||
|
import WelcomePageContent from './welcome-page-content'
|
||||||
|
|
||||||
|
// ── Mobile breakpoint ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function useIsMobile() {
|
||||||
|
const [mobile, setMobile] = useState(
|
||||||
|
() => window.matchMedia('(max-width: 767px)').matches
|
||||||
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(max-width: 767px)')
|
||||||
|
const handler = (e: MediaQueryListEvent) => setMobile(e.matches)
|
||||||
|
mq.addEventListener('change', handler)
|
||||||
|
return () => mq.removeEventListener('change', handler)
|
||||||
|
}, [])
|
||||||
|
return mobile
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tile zoom ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ZoomLevel = 0 | 1 | 1.35 | 1.75
|
||||||
|
const ZOOM_OPTIONS: { value: ZoomLevel; label: string }[] = [
|
||||||
|
{ value: 0, label: 'XS' },
|
||||||
|
{ value: 1, label: 'S' },
|
||||||
|
{ value: 1.35, label: 'M' },
|
||||||
|
{ value: 1.75, label: 'L' },
|
||||||
|
]
|
||||||
|
// Mobile offers 3 sizes: XS (rows), M (2 tiles), L (1 tile)
|
||||||
|
const MOBILE_ZOOM_OPTIONS: { value: ZoomLevel; label: string }[] = [
|
||||||
|
{ value: 0, label: 'XS' },
|
||||||
|
{ value: 1, label: 'M' },
|
||||||
|
{ value: 1.35, label: 'L' },
|
||||||
|
]
|
||||||
|
const ZOOM_STORAGE_KEY = 'lumiere-card-scale'
|
||||||
|
|
||||||
|
function useLumiereCardScale(): [ZoomLevel, (z: ZoomLevel) => void] {
|
||||||
|
const [scale, setScale] = useState<ZoomLevel>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(ZOOM_STORAGE_KEY)
|
||||||
|
if (stored) {
|
||||||
|
const val = parseFloat(stored)
|
||||||
|
if ((ZOOM_OPTIONS.map(o => o.value) as number[]).includes(val)) {
|
||||||
|
return val as ZoomLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// storage unavailable
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateScale = useCallback((z: ZoomLevel) => {
|
||||||
|
setScale(z)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ZOOM_STORAGE_KEY, String(z))
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return [scale, updateScale]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Format helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type FormatVariant = 'latex' | 'typst' | 'quarto' | 'quarto-slides'
|
||||||
|
|
||||||
|
function getFormatVariant(
|
||||||
|
compiler: ProjectCompiler | undefined,
|
||||||
|
quartoFlavor: 'revealjs' | 'pdf' | undefined
|
||||||
|
): FormatVariant {
|
||||||
|
if (compiler === 'quarto') {
|
||||||
|
return quartoFlavor === 'revealjs' ? 'quarto-slides' : 'quarto'
|
||||||
|
}
|
||||||
|
if (compiler === 'typst') return 'typst'
|
||||||
|
return 'latex'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormatLabel(variant: FormatVariant): string {
|
||||||
|
switch (variant) {
|
||||||
|
case 'typst':
|
||||||
|
return 'Typst'
|
||||||
|
case 'quarto':
|
||||||
|
return 'Quarto'
|
||||||
|
case 'quarto-slides':
|
||||||
|
return 'Quarto Slides'
|
||||||
|
default:
|
||||||
|
return 'LaTeX'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProjectCard = memo(function ProjectCard({
|
||||||
|
project,
|
||||||
|
}: {
|
||||||
|
project: Project
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { tags } = useProjectListContext()
|
||||||
|
const variant = getFormatVariant(project.compiler, project.quartoFlavor)
|
||||||
|
const rawOwner = getOwnerName(project)
|
||||||
|
const ownerName = rawOwner === 'You' ? t('you') : rawOwner
|
||||||
|
const date = fromNowDate(project.lastUpdated)
|
||||||
|
const initial = project.name.charAt(0).toUpperCase() || '?'
|
||||||
|
const projectTags = tags
|
||||||
|
.filter(tag => tag.project_ids?.includes(project.id))
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lumiere-card-wrapper">
|
||||||
|
<div className="lumiere-card-checkbox">
|
||||||
|
<ProjectCheckbox projectId={project.id} projectName={project.name} />
|
||||||
|
</div>
|
||||||
|
<div className={`lumiere-card lumiere-card--${variant}`} translate="no">
|
||||||
|
<a
|
||||||
|
href={`/project/${project.id}`}
|
||||||
|
className="lumiere-card-link"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
<div className="lumiere-card-thumb">
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||||
|
<img
|
||||||
|
className="lumiere-card-thumb-img"
|
||||||
|
src={`/project/${project.id}/thumbnail`}
|
||||||
|
aria-hidden="true"
|
||||||
|
onError={e => {
|
||||||
|
e.currentTarget.style.display = 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="lumiere-card-initial">{initial}</span>
|
||||||
|
</div>
|
||||||
|
<div className="lumiere-card-body">
|
||||||
|
<span className="lumiere-card-name">{project.name}</span>
|
||||||
|
<div className="lumiere-card-meta">
|
||||||
|
<span
|
||||||
|
className={`lumiere-format-badge lumiere-format-badge--${variant}`}
|
||||||
|
>
|
||||||
|
{getFormatLabel(variant)}
|
||||||
|
</span>
|
||||||
|
{ownerName && (
|
||||||
|
<span className="lumiere-card-owner" translate="yes">
|
||||||
|
{ownerName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{projectTags.map(tag => (
|
||||||
|
<OLTooltip
|
||||||
|
key={tag._id}
|
||||||
|
id={`tag-${tag._id}-${project.id}`}
|
||||||
|
description={tag.name}
|
||||||
|
overlayProps={{ placement: 'top' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="lumiere-card-tag-dot"
|
||||||
|
style={{ backgroundColor: getTagColor(tag) }}
|
||||||
|
translate="no"
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
))}</div>
|
||||||
|
<span className="lumiere-card-date">{date}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div className="lumiere-card-actions">
|
||||||
|
<CopyProjectButtonTooltip project={project} />
|
||||||
|
<DownloadProjectButtonTooltip project={project} />
|
||||||
|
{project.compiler === 'quarto' ? (
|
||||||
|
<DownloadPresentationButtonTooltip project={project} />
|
||||||
|
) : (
|
||||||
|
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
|
||||||
|
)}
|
||||||
|
<ArchiveProjectButtonTooltip project={project} />
|
||||||
|
<TrashProjectButtonTooltip project={project} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── XS compact row (reuses classic table cell components for full detail) ─────
|
||||||
|
|
||||||
|
const ProjectCardCompact = memo(function ProjectCardCompact({
|
||||||
|
project,
|
||||||
|
}: {
|
||||||
|
project: Project
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="lumiere-compact-row">
|
||||||
|
<div className="lumiere-compact-checkbox">
|
||||||
|
<ProjectCheckbox projectId={project.id} projectName={project.name} />
|
||||||
|
</div>
|
||||||
|
<div className="lumiere-compact-name-cell">
|
||||||
|
<a
|
||||||
|
href={`/project/${project.id}`}
|
||||||
|
className="lumiere-compact-name"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</a>
|
||||||
|
<InlineTags projectId={project.id} />
|
||||||
|
</div>
|
||||||
|
<div className="lumiere-compact-meta">
|
||||||
|
<div className="lumiere-compact-format">
|
||||||
|
<FormatCell project={project} />
|
||||||
|
</div>
|
||||||
|
<div className="lumiere-compact-owner" translate="no">
|
||||||
|
<OwnerCell project={project} />
|
||||||
|
</div>
|
||||||
|
<div className="lumiere-compact-date">
|
||||||
|
<LastUpdatedCell project={project} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lumiere-compact-actions">
|
||||||
|
{/* ⋮ dropdown on mobile (single button), icon strip on desktop */}
|
||||||
|
<div className="d-md-none">
|
||||||
|
<ActionsDropdown project={project} />
|
||||||
|
</div>
|
||||||
|
<div className="d-none d-md-flex">
|
||||||
|
<ActionsCell project={project} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function ProjectListLumiere() {
|
||||||
|
const navbarProps = getMeta('ol-navbar')
|
||||||
|
const footerProps = getMeta('ol-footer')
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [cardScale, setCardScale] = useLumiereCardScale()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const {
|
||||||
|
error,
|
||||||
|
visibleProjects,
|
||||||
|
totalProjectsCount,
|
||||||
|
searchText,
|
||||||
|
setSearchText,
|
||||||
|
filter,
|
||||||
|
tags,
|
||||||
|
selectedTagId,
|
||||||
|
selectedProjects,
|
||||||
|
selectOrUnselectAllProjects,
|
||||||
|
selectFilter,
|
||||||
|
} = useProjectListContext()
|
||||||
|
|
||||||
|
// On mobile: M option (scale=1) → 2 tiles/row; L (scale≥1.35) → 1 tile/row.
|
||||||
|
// CSS overrides the card grid columns on mobile; we only add a class for L.
|
||||||
|
const mobileIsL = isMobile && cardScale !== 0 && cardScale >= 1.35
|
||||||
|
const activeMobileZoom = cardScale === 0
|
||||||
|
? 0
|
||||||
|
: cardScale >= 1.35
|
||||||
|
? 1.35
|
||||||
|
: 1
|
||||||
|
|
||||||
|
const selectedTag = tags.find(tag => tag._id === selectedTagId)
|
||||||
|
const allSelected =
|
||||||
|
visibleProjects.length > 0 &&
|
||||||
|
selectedProjects.length === visibleProjects.length
|
||||||
|
|
||||||
|
const MOBILE_FILTERS: { f: Filter; label: string }[] = [
|
||||||
|
{ f: 'all', label: t('all_projects') },
|
||||||
|
{ f: 'owned', label: t('your_projects') },
|
||||||
|
{ f: 'shared', label: t('shared_with_you') },
|
||||||
|
{ f: 'archived', label: t('archived_projects') },
|
||||||
|
{ f: 'trashed', label: t('trashed_projects') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const checkAllRef = useRef<HTMLInputElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (checkAllRef.current) {
|
||||||
|
checkAllRef.current.indeterminate =
|
||||||
|
selectedProjects.length > 0 && !allSelected
|
||||||
|
}
|
||||||
|
}, [selectedProjects, allSelected])
|
||||||
|
|
||||||
|
const handleSelectAll = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
selectOrUnselectAllProjects(e.target.checked)
|
||||||
|
},
|
||||||
|
[selectOrUnselectAllProjects]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDeselectAll = useCallback(() => {
|
||||||
|
selectOrUnselectAllProjects(false)
|
||||||
|
}, [selectOrUnselectAllProjects])
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Keep project-ds-nav-page + website-redesign so the sidebar and navbar
|
||||||
|
// pick up all their existing CSS (scoped under those classes). The
|
||||||
|
// project-list-lumiere class then adds/overrides the card-grid styles.
|
||||||
|
<div className="project-ds-nav-page website-redesign project-list-lumiere">
|
||||||
|
<SystemMessages />
|
||||||
|
<DefaultNavbar {...navbarProps} showCloseIcon />
|
||||||
|
{/* project-list-wrapper is required by sidebar CSS */}
|
||||||
|
<div className="project-list-wrapper">
|
||||||
|
<SidebarDsNav />
|
||||||
|
<div className="project-ds-nav-content-and-messages lumiere-content-area">
|
||||||
|
<div className="project-ds-nav-content lumiere-scroll-area">
|
||||||
|
<main
|
||||||
|
className="project-ds-nav-main lumiere-main"
|
||||||
|
aria-labelledby="lumiere-title"
|
||||||
|
>
|
||||||
|
<UserNotifications />
|
||||||
|
{error && <DashApiError />}
|
||||||
|
<div className="lumiere-header">
|
||||||
|
<div className="lumiere-title-row">
|
||||||
|
<ProjectListTitle
|
||||||
|
filter={filter}
|
||||||
|
selectedTag={selectedTag}
|
||||||
|
selectedTagId={selectedTagId}
|
||||||
|
className="lumiere-title"
|
||||||
|
id="lumiere-title"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="lumiere-zoom-control d-none d-md-flex"
|
||||||
|
role="group"
|
||||||
|
aria-label={t('card_size')}
|
||||||
|
>
|
||||||
|
{ZOOM_OPTIONS.map(({ value, label }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={`lumiere-zoom-btn${cardScale === value ? ' active' : ''}`}
|
||||||
|
onClick={() => setCardScale(value)}
|
||||||
|
aria-pressed={cardScale === value}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Mobile-only: filter pills */}
|
||||||
|
<div className="d-flex d-md-none lumiere-mobile-toolbar">
|
||||||
|
<div
|
||||||
|
className="lumiere-mobile-filters"
|
||||||
|
role="group"
|
||||||
|
aria-label={t('filter_projects')}
|
||||||
|
>
|
||||||
|
{MOBILE_FILTERS.map(({ f, label }) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
type="button"
|
||||||
|
className={`lumiere-mobile-filter-pill${filter === f ? ' active' : ''}`}
|
||||||
|
onClick={() => selectFilter(f)}
|
||||||
|
aria-pressed={filter === f}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lumiere-header-actions">
|
||||||
|
<SearchForm
|
||||||
|
inputValue={searchText}
|
||||||
|
setInputValue={setSearchText}
|
||||||
|
filter={filter}
|
||||||
|
selectedTag={selectedTag}
|
||||||
|
/>
|
||||||
|
{/* On mobile: new project + zoom share a row below the search bar */}
|
||||||
|
<div className="lumiere-header-actions-row">
|
||||||
|
<NewProjectButton
|
||||||
|
id="lumiere-new-project-button"
|
||||||
|
showAddAffiliationWidget
|
||||||
|
/>
|
||||||
|
{/* Zoom control: on desktop it sits in the title row; on mobile it moves here */}
|
||||||
|
<div
|
||||||
|
className="lumiere-zoom-control lumiere-mobile-zoom d-md-none"
|
||||||
|
role="group"
|
||||||
|
aria-label={t('card_size')}
|
||||||
|
>
|
||||||
|
{MOBILE_ZOOM_OPTIONS.map(({ value, label }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={`lumiere-zoom-btn${activeMobileZoom === value ? ' active' : ''}`}
|
||||||
|
onClick={() => setCardScale(value)}
|
||||||
|
aria-pressed={activeMobileZoom === value}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedProjects.length > 0 && (
|
||||||
|
<div className="lumiere-selection-bar">
|
||||||
|
<div className="lumiere-selection-bar-left">
|
||||||
|
<OLFormCheckbox
|
||||||
|
autoComplete="off"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
inputRef={checkAllRef}
|
||||||
|
aria-label={t('select_all_projects')}
|
||||||
|
/>
|
||||||
|
<span className="lumiere-selection-count">
|
||||||
|
{t('n_projects_selected', {
|
||||||
|
count: selectedProjects.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProjectTools />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lumiere-selection-deselect"
|
||||||
|
onClick={handleDeselectAll}
|
||||||
|
>
|
||||||
|
{t('deselect_all')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{totalProjectsCount === 0 ? (
|
||||||
|
<WelcomePageContent />
|
||||||
|
) : visibleProjects.length === 0 ? (
|
||||||
|
<p className="lumiere-empty">{t('no_projects')}</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'lumiere-card-grid',
|
||||||
|
cardScale === 0 && 'lumiere-card-grid--compact',
|
||||||
|
mobileIsL && 'lumiere-card-grid--mobile-1col',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
style={cardScale === 0 || isMobile ? {} : ({ '--lum-card-scale': cardScale } as React.CSSProperties)}
|
||||||
|
>
|
||||||
|
{visibleProjects.map(project =>
|
||||||
|
cardScale === 0 ? (
|
||||||
|
<ProjectCardCompact key={project.id} project={project} />
|
||||||
|
) : (
|
||||||
|
<ProjectCard key={project.id} project={project} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<LoadMore />
|
||||||
|
</main>
|
||||||
|
<Footer {...footerProps} />
|
||||||
|
</div>
|
||||||
|
<CookieBanner />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,10 +17,12 @@ import DefaultNavbar from '@/shared/components/navbar/default-navbar'
|
|||||||
import Footer from '@/shared/components/footer/footer'
|
import Footer from '@/shared/components/footer/footer'
|
||||||
import WelcomePageContent from '@/features/project-list/components/welcome-page-content'
|
import WelcomePageContent from '@/features/project-list/components/welcome-page-content'
|
||||||
import { ProjectListDsNav } from '@/features/project-list/components/project-list-ds-nav'
|
import { ProjectListDsNav } from '@/features/project-list/components/project-list-ds-nav'
|
||||||
|
import { ProjectListLumiere } from '@/features/project-list/components/project-list-lumiere'
|
||||||
import { DsNavStyleProvider } from '@/features/project-list/components/use-is-ds-nav'
|
import { DsNavStyleProvider } from '@/features/project-list/components/use-is-ds-nav'
|
||||||
import CookieBanner from '@/shared/components/cookie-banner'
|
import CookieBanner from '@/shared/components/cookie-banner'
|
||||||
import useThemedPage from '@/shared/hooks/use-themed-page'
|
import useThemedPage from '@/shared/hooks/use-themed-page'
|
||||||
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
|
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
|
||||||
|
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
|
||||||
import { TutorialProvider } from '@/shared/context/tutorial-context'
|
import { TutorialProvider } from '@/shared/context/tutorial-context'
|
||||||
|
|
||||||
function ProjectListRoot() {
|
function ProjectListRoot() {
|
||||||
@@ -80,6 +82,9 @@ function ProjectListPageContent() {
|
|||||||
useThemedPage()
|
useThemedPage()
|
||||||
const { totalProjectsCount, isLoading, loadProgress } =
|
const { totalProjectsCount, isLoading, loadProgress } =
|
||||||
useProjectListContext()
|
useProjectListContext()
|
||||||
|
const {
|
||||||
|
userSettings: { overallTheme },
|
||||||
|
} = useUserSettingsContext()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
eventTracking.sendMB('loads_v2_dash', { page: 'projects' })
|
eventTracking.sendMB('loads_v2_dash', { page: 'projects' })
|
||||||
@@ -95,6 +100,14 @@ function ProjectListPageContent() {
|
|||||||
return loadingComponent
|
return loadingComponent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (overallTheme === 'lumiere-') {
|
||||||
|
return (
|
||||||
|
<DsNavStyleProvider>
|
||||||
|
<ProjectListLumiere />
|
||||||
|
</DsNavStyleProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (totalProjectsCount === 0) {
|
if (totalProjectsCount === 0) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -105,6 +118,7 @@ function ProjectListPageContent() {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DsNavStyleProvider>
|
<DsNavStyleProvider>
|
||||||
<ProjectListDsNav />
|
<ProjectListDsNav />
|
||||||
|
|||||||
+1
-1
@@ -66,7 +66,7 @@ function SidebarDsNav() {
|
|||||||
scrolledUp && 'show-shadow'
|
scrolledUp && 'show-shadow'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SidebarLowerSection showThemeToggle>
|
<SidebarLowerSection showThemeToggle showAccountIcons={false}>
|
||||||
<div className="project-list-sidebar-survey-wrapper">
|
<div className="project-list-sidebar-survey-wrapper">
|
||||||
<SurveyWidgetDsNav />
|
<SurveyWidgetDsNav />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { useTranslation } from 'react-i18next'
|
|||||||
|
|
||||||
const getIcon = (theme: OverallThemeMeta) => {
|
const getIcon = (theme: OverallThemeMeta) => {
|
||||||
switch (theme.val) {
|
switch (theme.val) {
|
||||||
|
case 'lumiere-':
|
||||||
|
return 'auto_awesome'
|
||||||
case 'light-':
|
case 'light-':
|
||||||
return 'light_mode'
|
return 'light_mode'
|
||||||
case 'system':
|
case 'system':
|
||||||
|
|||||||
+4
@@ -98,6 +98,10 @@ function CompileAndDownloadProjectPDFButton({
|
|||||||
const outputFile = data.outputFiles
|
const outputFile = data.outputFiles
|
||||||
.filter((file: { path: string }) => file.path === 'output.pdf')
|
.filter((file: { path: string }) => file.path === 'output.pdf')
|
||||||
.pop()
|
.pop()
|
||||||
|
if (!outputFile) {
|
||||||
|
setShowErrorModal(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
compileGroup: data.compileGroup,
|
compileGroup: data.compileGroup,
|
||||||
|
|||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
import { memo, useCallback, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Project } from '../../../../../../../../types/project/dashboard/api'
|
||||||
|
import {
|
||||||
|
Dropdown,
|
||||||
|
DropdownItem,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownToggle,
|
||||||
|
} from '@/shared/components/dropdown/dropdown-menu'
|
||||||
|
import {
|
||||||
|
OLModal,
|
||||||
|
OLModalBody,
|
||||||
|
OLModalFooter,
|
||||||
|
OLModalHeader,
|
||||||
|
OLModalTitle,
|
||||||
|
} from '@/shared/components/ol/ol-modal'
|
||||||
|
import OLButton from '@/shared/components/ol/ol-button'
|
||||||
|
import OLSpinner from '@/shared/components/ol/ol-spinner'
|
||||||
|
import LoadingSpinner from '@/shared/components/loading-spinner'
|
||||||
|
import MaterialIcon from '@/shared/components/material-icon'
|
||||||
|
|
||||||
|
type ExportFormat = 'html' | 'pdf'
|
||||||
|
|
||||||
|
type DownloadPresentationButtonProps = {
|
||||||
|
project: Project
|
||||||
|
children: (
|
||||||
|
startExport: (format: ExportFormat) => void,
|
||||||
|
exporting: ExportFormat | null
|
||||||
|
) => React.ReactElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadPresentationButton({
|
||||||
|
project,
|
||||||
|
children,
|
||||||
|
}: DownloadPresentationButtonProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [exporting, setExporting] = useState<ExportFormat | null>(null)
|
||||||
|
const [exportError, setExportError] = useState<string | null>(null)
|
||||||
|
const requestIdRef = useRef(0)
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
requestIdRef.current += 1
|
||||||
|
setExporting(null)
|
||||||
|
setExportError(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startExport = useCallback(
|
||||||
|
async (format: ExportFormat) => {
|
||||||
|
const requestId = ++requestIdRef.current
|
||||||
|
setExportError(null)
|
||||||
|
setExporting(format)
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/project/${project.id}/presentation-export/${format}`,
|
||||||
|
{ credentials: 'same-origin' }
|
||||||
|
)
|
||||||
|
if (requestId !== requestIdRef.current) return
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
if (requestId !== requestIdRef.current) return
|
||||||
|
setExporting(null)
|
||||||
|
setExportError(text || `Export failed (HTTP ${response.status})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const blob = await response.blob()
|
||||||
|
if (requestId !== requestIdRef.current) return
|
||||||
|
const disposition = response.headers.get('Content-Disposition')
|
||||||
|
const match = disposition?.match(/filename="?([^"]+)"?/)
|
||||||
|
const filename =
|
||||||
|
match ? match[1] : `presentation.${format === 'pdf' ? 'pdf' : 'html'}`
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
setExporting(null)
|
||||||
|
// Revoke after a delay so the browser has time to initiate the download
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 10000)
|
||||||
|
} catch (err) {
|
||||||
|
if (requestId !== requestIdRef.current) return
|
||||||
|
setExporting(null)
|
||||||
|
setExportError(err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[project.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children(startExport, exporting)}
|
||||||
|
<OLModal
|
||||||
|
show={exporting !== null || exportError !== null}
|
||||||
|
onHide={dismiss}
|
||||||
|
>
|
||||||
|
<OLModalHeader closeButton>
|
||||||
|
<OLModalTitle>
|
||||||
|
{exportError
|
||||||
|
? t('presentation_export_failed')
|
||||||
|
: t('preparing_your_download')}
|
||||||
|
</OLModalTitle>
|
||||||
|
</OLModalHeader>
|
||||||
|
<OLModalBody>
|
||||||
|
{exporting !== null ? (
|
||||||
|
<LoadingSpinner
|
||||||
|
loadingText={t('presentation_export_can_take_a_moment')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
maxHeight: '50vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
marginBottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{exportError}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</OLModalBody>
|
||||||
|
<OLModalFooter>
|
||||||
|
<OLButton variant="secondary" onClick={dismiss}>
|
||||||
|
{t('close')}
|
||||||
|
</OLButton>
|
||||||
|
</OLModalFooter>
|
||||||
|
</OLModal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For ActionsCell (desktop icon buttons row): an icon button that opens a
|
||||||
|
// dropdown with PDF and HTML export choices.
|
||||||
|
const DownloadPresentationButtonTooltip = memo(
|
||||||
|
function DownloadPresentationButtonTooltip({
|
||||||
|
project,
|
||||||
|
}: { project: Project }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (project.compiler !== 'quarto') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DownloadPresentationButton project={project}>
|
||||||
|
{(startExport, exporting) => (
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownToggle
|
||||||
|
id={`download-presentation-toggle-${project.id}`}
|
||||||
|
variant="link"
|
||||||
|
className="action-btn"
|
||||||
|
aria-label={t('download')}
|
||||||
|
>
|
||||||
|
{exporting !== null ? (
|
||||||
|
<OLSpinner size="sm" />
|
||||||
|
) : (
|
||||||
|
<MaterialIcon type="picture_as_pdf" />
|
||||||
|
)}
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu
|
||||||
|
popperConfig={{ strategy: 'fixed' }}
|
||||||
|
renderOnMount
|
||||||
|
flip
|
||||||
|
>
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
onClick={() => startExport('html')}
|
||||||
|
disabled={exporting !== null}
|
||||||
|
leadingIcon="download"
|
||||||
|
>
|
||||||
|
{t('download_as_standalone_html')}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
onClick={() => startExport('pdf')}
|
||||||
|
disabled={exporting !== null}
|
||||||
|
leadingIcon="picture_as_pdf"
|
||||||
|
>
|
||||||
|
{t('download_as_pdf_slides')}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
</DownloadPresentationButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default memo(DownloadPresentationButton)
|
||||||
|
export { DownloadPresentationButtonTooltip }
|
||||||
+9
-1
@@ -8,17 +8,25 @@ import { DownloadProjectButtonTooltip } from './action-buttons/download-project-
|
|||||||
import { LeaveProjectButtonTooltip } from './action-buttons/leave-project-button'
|
import { LeaveProjectButtonTooltip } from './action-buttons/leave-project-button'
|
||||||
import { DeleteProjectButtonTooltip } from './action-buttons/delete-project-button'
|
import { DeleteProjectButtonTooltip } from './action-buttons/delete-project-button'
|
||||||
import { CompileAndDownloadProjectPDFButtonTooltip } from './action-buttons/compile-and-download-project-pdf-button'
|
import { CompileAndDownloadProjectPDFButtonTooltip } from './action-buttons/compile-and-download-project-pdf-button'
|
||||||
|
import { DownloadPresentationButtonTooltip } from './action-buttons/download-presentation-button'
|
||||||
|
|
||||||
type ActionsCellProps = {
|
type ActionsCellProps = {
|
||||||
project: Project
|
project: Project
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isQuartoSlides = (project: Project) =>
|
||||||
|
project.compiler === 'quarto'
|
||||||
|
|
||||||
export default function ActionsCell({ project }: ActionsCellProps) {
|
export default function ActionsCell({ project }: ActionsCellProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CopyProjectButtonTooltip project={project} />
|
<CopyProjectButtonTooltip project={project} />
|
||||||
<DownloadProjectButtonTooltip project={project} />
|
<DownloadProjectButtonTooltip project={project} />
|
||||||
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
|
{isQuartoSlides(project) ? (
|
||||||
|
<DownloadPresentationButtonTooltip project={project} />
|
||||||
|
) : (
|
||||||
|
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
|
||||||
|
)}
|
||||||
<ArchiveProjectButtonTooltip project={project} />
|
<ArchiveProjectButtonTooltip project={project} />
|
||||||
<TrashProjectButtonTooltip project={project} />
|
<TrashProjectButtonTooltip project={project} />
|
||||||
<UnarchiveProjectButtonTooltip project={project} />
|
<UnarchiveProjectButtonTooltip project={project} />
|
||||||
|
|||||||
+12
-3
@@ -4,12 +4,21 @@ import { ProjectCompiler } from '../../../../../../../types/project-settings'
|
|||||||
// Map the stored compiler engine to the document format the project produces.
|
// Map the stored compiler engine to the document format the project produces.
|
||||||
// CLSI dispatches the real engine from the root file's extension, but the
|
// CLSI dispatches the real engine from the root file's extension, but the
|
||||||
// compiler field is a faithful, cheap proxy for the project's format.
|
// compiler field is a faithful, cheap proxy for the project's format.
|
||||||
function formatLabel(compiler: ProjectCompiler | undefined): {
|
function formatLabel(
|
||||||
|
compiler: ProjectCompiler | undefined,
|
||||||
|
quartoFlavor: 'revealjs' | 'pdf' | undefined
|
||||||
|
): {
|
||||||
label: string
|
label: string
|
||||||
variant: 'quarto' | 'typst' | 'latex'
|
variant: 'quarto-slides' | 'quarto' | 'typst' | 'latex'
|
||||||
} {
|
} {
|
||||||
switch (compiler) {
|
switch (compiler) {
|
||||||
case 'quarto':
|
case 'quarto':
|
||||||
|
if (quartoFlavor === 'revealjs') {
|
||||||
|
return { label: 'Quarto Slides', variant: 'quarto-slides' }
|
||||||
|
}
|
||||||
|
if (quartoFlavor === 'pdf') {
|
||||||
|
return { label: 'Quarto PDF', variant: 'quarto' }
|
||||||
|
}
|
||||||
return { label: 'Quarto', variant: 'quarto' }
|
return { label: 'Quarto', variant: 'quarto' }
|
||||||
case 'typst':
|
case 'typst':
|
||||||
return { label: 'Typst', variant: 'typst' }
|
return { label: 'Typst', variant: 'typst' }
|
||||||
@@ -24,7 +33,7 @@ type FormatCellProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function FormatCell({ project }: FormatCellProps) {
|
export default function FormatCell({ project }: FormatCellProps) {
|
||||||
const { label, variant } = formatLabel(project.compiler)
|
const { label, variant } = formatLabel(project.compiler, project.quartoFlavor)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
|||||||
+2
-4
@@ -28,9 +28,10 @@ function ProjectListTableRow({ project, selected }: ProjectListTableRowProps) {
|
|||||||
</a>{' '}
|
</a>{' '}
|
||||||
<InlineTags className="d-none d-md-inline" projectId={project.id} />
|
<InlineTags className="d-none d-md-inline" projectId={project.id} />
|
||||||
</td>
|
</td>
|
||||||
<td className="dash-cell-date-owner pb-0 d-md-none">
|
<td className="dash-cell-meta d-md-none">
|
||||||
<LastUpdatedCell project={project} />
|
<LastUpdatedCell project={project} />
|
||||||
{ownerName ? <ProjectListOwnerName ownerName={ownerName} /> : null}
|
{ownerName ? <ProjectListOwnerName ownerName={ownerName} /> : null}
|
||||||
|
<InlineTags projectId={project.id} className="ms-1" />
|
||||||
</td>
|
</td>
|
||||||
<td className="dash-cell-format d-none d-md-table-cell">
|
<td className="dash-cell-format d-none d-md-table-cell">
|
||||||
<FormatCell project={project} />
|
<FormatCell project={project} />
|
||||||
@@ -41,9 +42,6 @@ function ProjectListTableRow({ project, selected }: ProjectListTableRowProps) {
|
|||||||
<td className="dash-cell-date d-none d-md-table-cell">
|
<td className="dash-cell-date d-none d-md-table-cell">
|
||||||
<LastUpdatedCell project={project} />
|
<LastUpdatedCell project={project} />
|
||||||
</td>
|
</td>
|
||||||
<td className="dash-cell-tag pt-0 d-md-none">
|
|
||||||
<InlineTags projectId={project.id} />
|
|
||||||
</td>
|
|
||||||
<td className="dash-cell-actions">
|
<td className="dash-cell-actions">
|
||||||
<div className="d-none d-lg-block">
|
<div className="d-none d-lg-block">
|
||||||
<ActionsCell project={project} />
|
<ActionsCell project={project} />
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function WelcomeMessage() {
|
|||||||
{wikiEnabled && (
|
{wikiEnabled && (
|
||||||
<WelcomeMessageLink
|
<WelcomeMessageLink
|
||||||
imgSrc={learnLatexImage}
|
imgSrc={learnLatexImage}
|
||||||
title="Learn LaTeX with a tutorial"
|
title={t('learn_latex_with_a_tutorial')}
|
||||||
href="/learn/latex/Learn_LaTeX_in_30_minutes"
|
href="/learn/latex/Learn_LaTeX_in_30_minutes"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
/>
|
/>
|
||||||
@@ -38,7 +38,7 @@ export default function WelcomeMessage() {
|
|||||||
{templatesEnabled && (
|
{templatesEnabled && (
|
||||||
<WelcomeMessageLink
|
<WelcomeMessageLink
|
||||||
imgSrc={browseTemplatesImage}
|
imgSrc={browseTemplatesImage}
|
||||||
title="Browse templates"
|
title={t('browse_templates')}
|
||||||
href="/templates"
|
href="/templates"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+14
-6
@@ -7,6 +7,7 @@ import { usePermissionsContext } from '@/features/ide-react/context/permissions-
|
|||||||
import { ProjectCompiler } from '@ol-types/project-settings'
|
import { ProjectCompiler } from '@ol-types/project-settings'
|
||||||
import { useSetCompilationSettingWithEvent } from '@/features/editor-left-menu/hooks/use-set-compilation-setting'
|
import { useSetCompilationSettingWithEvent } from '@/features/editor-left-menu/hooks/use-set-compilation-setting'
|
||||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||||
|
import { findInTree } from '@/features/file-tree/util/find-in-tree'
|
||||||
|
|
||||||
// Which compiler engines make sense for a given root-file extension. CLSI
|
// Which compiler engines make sense for a given root-file extension. CLSI
|
||||||
// dispatches the real engine from this extension (.qmd → Quarto, .typ → Typst,
|
// dispatches the real engine from this extension (.qmd → Quarto, .typ → Typst,
|
||||||
@@ -34,7 +35,7 @@ export default function CompilerSetting() {
|
|||||||
const [compilerOptions] = useState(() => getCompilerOptions())
|
const [compilerOptions] = useState(() => getCompilerOptions())
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { write } = usePermissionsContext()
|
const { write } = usePermissionsContext()
|
||||||
const { docs } = useFileTreeData()
|
const { docs, fileTreeData } = useFileTreeData()
|
||||||
const changeCompiler = useSetCompilationSettingWithEvent(
|
const changeCompiler = useSetCompilationSettingWithEvent(
|
||||||
'compiler',
|
'compiler',
|
||||||
setCompiler
|
setCompiler
|
||||||
@@ -43,14 +44,21 @@ export default function CompilerSetting() {
|
|||||||
// Disable the engines that don't apply to the current root file's extension.
|
// Disable the engines that don't apply to the current root file's extension.
|
||||||
// The currently-selected engine is always left enabled so it keeps showing.
|
// The currently-selected engine is always left enabled so it keeps showing.
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
const rootDoc = rootDocId
|
let rootDocName: string | undefined
|
||||||
? docs?.find(doc => doc.doc.id === rootDocId)
|
if (rootDocId) {
|
||||||
: undefined
|
const fromDocs = docs?.find(doc => doc.doc.id === rootDocId)
|
||||||
const extension = rootDoc?.doc.name.split('.').pop()?.toLowerCase()
|
if (fromDocs) {
|
||||||
|
rootDocName = fromDocs.doc.name
|
||||||
|
} else if (fileTreeData) {
|
||||||
|
// Fallback: look up directly in tree in case of ID format mismatch
|
||||||
|
const found = findInTree(fileTreeData, rootDocId)
|
||||||
|
if (found?.type === 'doc') rootDocName = found.entity.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const extension = rootDocName?.split('.').pop()?.toLowerCase()
|
||||||
const allowed = extension ? ENGINES_BY_EXTENSION[extension] : undefined
|
const allowed = extension ? ENGINES_BY_EXTENSION[extension] : undefined
|
||||||
|
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
// Unknown / no root file: don't restrict anything.
|
|
||||||
return compilerOptions
|
return compilerOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import { useCallback, useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
import Setting from '../setting'
|
||||||
|
import OLFormSelect from '@/shared/components/ol/ol-form-select'
|
||||||
|
|
||||||
|
export default function InterfaceLanguageSetting() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const currentLangCode = getMeta('ol-i18n').currentLangCode
|
||||||
|
const footerMeta = getMeta('ol-footer')
|
||||||
|
const availableLanguages: string[] = useMemo(
|
||||||
|
() => footerMeta?.availableLanguages ?? [],
|
||||||
|
[footerMeta]
|
||||||
|
)
|
||||||
|
const translatedLanguages: Record<string, string> = useMemo(
|
||||||
|
() => footerMeta?.translatedLanguages ?? {},
|
||||||
|
[footerMeta]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const lng = event.target.value
|
||||||
|
const returnTo = encodeURIComponent(window.location.pathname)
|
||||||
|
window.location.href = `/set-language?lng=${encodeURIComponent(lng)}&return_to=${returnTo}`
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (availableLanguages.length <= 1) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Setting controlId="interfaceLanguage" label={t('interface_language')}>
|
||||||
|
<OLFormSelect
|
||||||
|
id="interfaceLanguage"
|
||||||
|
className="ide-dropdown-setting ide-dropdown-setting-wide"
|
||||||
|
size="sm"
|
||||||
|
value={currentLangCode}
|
||||||
|
onChange={handleChange}
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
{availableLanguages
|
||||||
|
.filter(lng => translatedLanguages[lng])
|
||||||
|
.map(lng => (
|
||||||
|
<option key={lng} value={lng}>
|
||||||
|
{translatedLanguages[lng]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</OLFormSelect>
|
||||||
|
</Setting>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import PDFViewerSetting from '@/features/settings/components/editor-settings/pdf
|
|||||||
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
||||||
import SpellCheckSetting from '@/features/settings/components/editor-settings/spell-check-setting'
|
import SpellCheckSetting from '@/features/settings/components/editor-settings/spell-check-setting'
|
||||||
import DictionarySetting from '@/features/settings/components/editor-settings/dictionary-setting'
|
import DictionarySetting from '@/features/settings/components/editor-settings/dictionary-setting'
|
||||||
|
import InterfaceLanguageSetting from '@/features/settings/components/editor-settings/interface-language-setting'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import BreadcrumbsSetting from '@/features/settings/components/editor-settings/breadcrumbs-setting'
|
import BreadcrumbsSetting from '@/features/settings/components/editor-settings/breadcrumbs-setting'
|
||||||
import NonBlinkingCursorSetting from '@/features/settings/components/editor-settings/non-blinking-cursor-setting'
|
import NonBlinkingCursorSetting from '@/features/settings/components/editor-settings/non-blinking-cursor-setting'
|
||||||
@@ -165,6 +166,10 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
|
|||||||
key: 'dictionary-settings',
|
key: 'dictionary-settings',
|
||||||
component: <DictionarySetting />,
|
component: <DictionarySetting />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'interfaceLanguage',
|
||||||
|
component: <InterfaceLanguageSetting />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
...spellcheckExtraSections,
|
...spellcheckExtraSections,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { EditorSelection } from '@codemirror/state'
|
||||||
|
import { EditorView } from '@codemirror/view'
|
||||||
|
import { syntaxTree } from '@codemirror/language'
|
||||||
|
import { matchingAncestor } from '../utils/tree-operations/ancestors'
|
||||||
|
import { type TypstFormattingNode } from '../utils/tree-operations/formatting'
|
||||||
|
import { wrapRanges } from './ranges'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle Typst symmetric markup (e.g. *bold* or _italic_).
|
||||||
|
*
|
||||||
|
* If the cursor/selection is already inside a matching syntax node the
|
||||||
|
* surrounding markers are removed; otherwise the selection is wrapped.
|
||||||
|
*/
|
||||||
|
export const toggleTypstMarkup =
|
||||||
|
(marker: string, nodeTypeName: TypstFormattingNode) =>
|
||||||
|
(view: EditorView): boolean => {
|
||||||
|
if (view.state.readOnly) return false
|
||||||
|
|
||||||
|
view.dispatch(
|
||||||
|
view.state.changeByRange(range => {
|
||||||
|
const tree = syntaxTree(view.state)
|
||||||
|
const node = tree.resolveInner(range.from, -1)
|
||||||
|
const formattingNode = matchingAncestor(
|
||||||
|
node,
|
||||||
|
n => n.type.name === nodeTypeName
|
||||||
|
)
|
||||||
|
|
||||||
|
if (formattingNode) {
|
||||||
|
const mLen = marker.length
|
||||||
|
const fFrom = formattingNode.from
|
||||||
|
const fTo = formattingNode.to
|
||||||
|
|
||||||
|
// Adjust the selection for the two deletions applied simultaneously.
|
||||||
|
// Positions after fFrom shift left by mLen (opening deletion).
|
||||||
|
// Positions at or after fTo shift left by another mLen (closing deletion).
|
||||||
|
const newFrom =
|
||||||
|
range.from > fFrom ? range.from - mLen : range.from
|
||||||
|
const newTo =
|
||||||
|
range.to -
|
||||||
|
(range.to > fFrom ? mLen : 0) -
|
||||||
|
(range.to >= fTo ? mLen : 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
changes: [
|
||||||
|
{ from: fFrom, to: fFrom + mLen, insert: '' },
|
||||||
|
{ from: fTo - mLen, to: fTo, insert: '' },
|
||||||
|
],
|
||||||
|
range: range.empty
|
||||||
|
? EditorSelection.cursor(newFrom)
|
||||||
|
: EditorSelection.range(newFrom, newTo),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap: insert markers around the selection
|
||||||
|
const content = view.state.sliceDoc(range.from, range.to)
|
||||||
|
return {
|
||||||
|
changes: {
|
||||||
|
from: range.from,
|
||||||
|
to: range.to,
|
||||||
|
insert: `${marker}${content}${marker}`,
|
||||||
|
},
|
||||||
|
range: range.empty
|
||||||
|
? EditorSelection.cursor(range.from + marker.length)
|
||||||
|
: EditorSelection.range(
|
||||||
|
range.from + marker.length,
|
||||||
|
range.to + marker.length
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ scrollIntoView: true }
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wraps selection in #link("")[…] and places the cursor in the URL field.
|
||||||
|
// Prefix breakdown: # l i n k ( " " ) [ = 10 chars; URL slot is at offset 7.
|
||||||
|
export const wrapTypstLink = wrapRanges(
|
||||||
|
'#link("")[',
|
||||||
|
']',
|
||||||
|
false,
|
||||||
|
range => EditorSelection.cursor(range.from - 3)
|
||||||
|
)
|
||||||
@@ -12,7 +12,7 @@ function SwitchToPDFButton() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pdfLayout === 'sideBySide') {
|
if (pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+60
-1
@@ -12,7 +12,10 @@ import { MathDropdown } from './math-dropdown'
|
|||||||
import { InsertListDropdown } from './insert-list-dropdown'
|
import { InsertListDropdown } from './insert-list-dropdown'
|
||||||
import { TableDropdown } from './table-dropdown'
|
import { TableDropdown } from './table-dropdown'
|
||||||
import { LegacyTableDropdown } from './table-inserter-dropdown-legacy'
|
import { LegacyTableDropdown } from './table-inserter-dropdown-legacy'
|
||||||
import { withinFormattingCommand } from '@/features/source-editor/utils/tree-operations/formatting'
|
import {
|
||||||
|
withinFormattingCommand,
|
||||||
|
withinTypstFormatting,
|
||||||
|
} from '@/features/source-editor/utils/tree-operations/formatting'
|
||||||
import { isMac } from '@/shared/utils/os'
|
import { isMac } from '@/shared/utils/os'
|
||||||
import { useProjectContext } from '@/shared/context/project-context'
|
import { useProjectContext } from '@/shared/context/project-context'
|
||||||
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
|
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
|
||||||
@@ -41,6 +44,7 @@ export const ToolbarItems: FC<{
|
|||||||
const { features } = useProjectContext()
|
const { features } = useProjectContext()
|
||||||
const permissions = usePermissionsContext()
|
const permissions = usePermissionsContext()
|
||||||
const isActive = withinFormattingCommand(state)
|
const isActive = withinFormattingCommand(state)
|
||||||
|
const isTypstActive = withinTypstFormatting(state)
|
||||||
|
|
||||||
const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
|
const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
|
||||||
const showGroup = (group: string) => !overflowed || overflowed.has(group)
|
const showGroup = (group: string) => !overflowed || overflowed.has(group)
|
||||||
@@ -189,6 +193,61 @@ export const ToolbarItems: FC<{
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{languageName === 'typst' && (
|
||||||
|
<>
|
||||||
|
{showGroup('group-format') && (
|
||||||
|
<div
|
||||||
|
className="ol-cm-toolbar-button-group"
|
||||||
|
aria-label={t('toolbar_text_style')}
|
||||||
|
>
|
||||||
|
<ToolbarButton
|
||||||
|
id="toolbar-format-bold"
|
||||||
|
label={t('toolbar_bold')}
|
||||||
|
command={commands.toggleTypstBold}
|
||||||
|
active={isTypstActive('Strong')}
|
||||||
|
icon="format_bold"
|
||||||
|
shortcut={isMac ? '⌘B' : 'Ctrl+B'}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
id="toolbar-format-italic"
|
||||||
|
label={t('toolbar_italic')}
|
||||||
|
command={commands.toggleTypstItalic}
|
||||||
|
active={isTypstActive('Emphasis')}
|
||||||
|
icon="format_italic"
|
||||||
|
shortcut={isMac ? '⌘I' : 'Ctrl+I'}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
id="toolbar-format-underline"
|
||||||
|
label={t('toolbar_underline')}
|
||||||
|
command={commands.wrapTypstUnderline}
|
||||||
|
icon="format_underlined"
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
id="toolbar-format-smallcaps"
|
||||||
|
label={t('toolbar_smallcaps')}
|
||||||
|
command={commands.wrapTypstSmallcaps}
|
||||||
|
icon="Sc"
|
||||||
|
textIcon
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showGroup('group-misc') && (
|
||||||
|
<div
|
||||||
|
className="ol-cm-toolbar-button-group"
|
||||||
|
data-overflow="group-misc"
|
||||||
|
aria-label={t('toolbar_insert_misc')}
|
||||||
|
>
|
||||||
|
<ToolbarButton
|
||||||
|
id="toolbar-typst-href"
|
||||||
|
label={t('toolbar_insert_link')}
|
||||||
|
command={commands.wrapTypstLink}
|
||||||
|
icon="add_link"
|
||||||
|
shortcut={isMac ? '⌘K' : 'Ctrl+K'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,5 +46,6 @@ export const classHighlighter = tagHighlighter([
|
|||||||
{ tag: tags.invalid, class: 'tok-invalid' },
|
{ tag: tags.invalid, class: 'tok-invalid' },
|
||||||
{ tag: tags.punctuation, class: 'tok-punctuation' },
|
{ tag: tags.punctuation, class: 'tok-punctuation' },
|
||||||
// additional
|
// additional
|
||||||
|
{ tag: tags.attributeName, class: 'tok-attributeName' },
|
||||||
{ tag: tags.attributeValue, class: 'tok-attributeValue' },
|
{ tag: tags.attributeValue, class: 'tok-attributeValue' },
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -203,6 +203,9 @@ const staticTheme = EditorView.theme({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
fontWeight: 'normal',
|
fontWeight: 'normal',
|
||||||
},
|
},
|
||||||
|
// Bold and italic markup (e.g. *strong* _emphasis_ in Typst and Markdown)
|
||||||
|
'.tok-strong': { fontWeight: 'bold' },
|
||||||
|
'.tok-emphasis': { fontStyle: 'italic' },
|
||||||
'.cm-selectionLayer': {
|
'.cm-selectionLayer': {
|
||||||
zIndex: -10,
|
zIndex: -10,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
} from '@codemirror/search'
|
} from '@codemirror/search'
|
||||||
import { sendMB } from '@/infrastructure/event-tracking'
|
import { sendMB } from '@/infrastructure/event-tracking'
|
||||||
import { toggleRanges, wrapRanges } from '../../commands/ranges'
|
import { toggleRanges, wrapRanges } from '../../commands/ranges'
|
||||||
|
import {
|
||||||
|
toggleTypstMarkup,
|
||||||
|
wrapTypstLink,
|
||||||
|
} from '../../commands/typst-ranges'
|
||||||
import {
|
import {
|
||||||
ancestorListType,
|
ancestorListType,
|
||||||
toggleListForRanges,
|
toggleListForRanges,
|
||||||
@@ -25,6 +29,11 @@ import { sendSearchEvent } from '@/features/event-tracking/search-events'
|
|||||||
|
|
||||||
export const toggleBold = toggleRanges('\\textbf')
|
export const toggleBold = toggleRanges('\\textbf')
|
||||||
export const toggleItalic = toggleRanges('\\textit')
|
export const toggleItalic = toggleRanges('\\textit')
|
||||||
|
export const toggleTypstBold = toggleTypstMarkup('*', 'Strong')
|
||||||
|
export const toggleTypstItalic = toggleTypstMarkup('_', 'Emphasis')
|
||||||
|
export const wrapTypstUnderline = wrapRanges('#underline[', ']')
|
||||||
|
export const wrapTypstSmallcaps = wrapRanges('#smallcaps[', ']')
|
||||||
|
export { wrapTypstLink }
|
||||||
|
|
||||||
// TODO: apply as a snippet?
|
// TODO: apply as a snippet?
|
||||||
// TODO: read URL from clipboard?
|
// TODO: read URL from clipboard?
|
||||||
|
|||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
} from '@codemirror/view'
|
||||||
|
import { EditorState, Range } from '@codemirror/state'
|
||||||
|
import { syntaxTree } from '@codemirror/language'
|
||||||
|
import { Tree } from '@lezer/common'
|
||||||
|
import { selectionIntersects } from './selection'
|
||||||
|
|
||||||
|
function shouldDecorate(
|
||||||
|
state: EditorState,
|
||||||
|
from: number,
|
||||||
|
to: number
|
||||||
|
): boolean {
|
||||||
|
return state.readOnly || !selectionIntersects(state.selection, { from, to })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ATX_HEADING_LEVEL: Record<string, string> = {
|
||||||
|
ATXHeading1: '1',
|
||||||
|
ATXHeading2: '2',
|
||||||
|
ATXHeading3: '3',
|
||||||
|
ATXHeading4: '4',
|
||||||
|
ATXHeading5: '5',
|
||||||
|
ATXHeading6: '6',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const quartoDecorations = ViewPlugin.define(
|
||||||
|
view => {
|
||||||
|
const createDecorations = (
|
||||||
|
state: EditorState,
|
||||||
|
tree: Tree
|
||||||
|
): DecorationSet => {
|
||||||
|
const decorations: Range<Decoration>[] = []
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
tree.iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter(nodeRef) {
|
||||||
|
const { from: nFrom, to: nTo } = nodeRef
|
||||||
|
const level = ATX_HEADING_LEVEL[nodeRef.type.name]
|
||||||
|
|
||||||
|
// ── ATX headings: # Title, ## Title, … ─────────────────────────
|
||||||
|
if (level !== undefined) {
|
||||||
|
const mark = nodeRef.node.getChild('HeaderMark')
|
||||||
|
if (mark) {
|
||||||
|
const cls = `ol-cm-md-heading ol-cm-md-h${level}`
|
||||||
|
if (shouldDecorate(state, nFrom, nTo)) {
|
||||||
|
// Hide the `# ` prefix (mark + optional trailing space)
|
||||||
|
const afterMark = state.doc.sliceString(mark.to, mark.to + 1)
|
||||||
|
const contentFrom =
|
||||||
|
afterMark === ' ' ? mark.to + 1 : mark.to
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(nFrom, contentFrom)
|
||||||
|
)
|
||||||
|
if (contentFrom < nTo) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({ class: cls }).range(contentFrom, nTo)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (nodeRef.type.name) {
|
||||||
|
// ── Bold: **text** or __text__ ─────────────────────────────────
|
||||||
|
case 'StrongEmphasis': {
|
||||||
|
if (shouldDecorate(state, nFrom, nTo)) {
|
||||||
|
const marks = nodeRef.node.getChildren('EmphasisMark')
|
||||||
|
const first = marks[0]
|
||||||
|
const last = marks[marks.length - 1]
|
||||||
|
if (first && last && first !== last) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(first.from, first.to)
|
||||||
|
)
|
||||||
|
if (last.from > first.to) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: 'ol-cm-md-strong',
|
||||||
|
inclusive: true,
|
||||||
|
}).range(first.to, last.from)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(last.from, last.to)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Italic: *text* or _text_ ────────────────────────────────────
|
||||||
|
case 'Emphasis': {
|
||||||
|
if (shouldDecorate(state, nFrom, nTo)) {
|
||||||
|
const marks = nodeRef.node.getChildren('EmphasisMark')
|
||||||
|
const first = marks[0]
|
||||||
|
const last = marks[marks.length - 1]
|
||||||
|
if (first && last && first !== last) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(first.from, first.to)
|
||||||
|
)
|
||||||
|
if (last.from > first.to) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: 'ol-cm-md-emph',
|
||||||
|
inclusive: true,
|
||||||
|
}).range(first.to, last.from)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(last.from, last.to)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Strikethrough: ~~text~~ ─────────────────────────────────────
|
||||||
|
case 'Strikethrough': {
|
||||||
|
if (shouldDecorate(state, nFrom, nTo)) {
|
||||||
|
const marks = nodeRef.node.getChildren('StrikethroughMark')
|
||||||
|
const first = marks[0]
|
||||||
|
const last = marks[marks.length - 1]
|
||||||
|
if (first && last && first !== last) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(first.from, first.to)
|
||||||
|
)
|
||||||
|
if (last.from > first.to) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: 'ol-cm-md-strikethrough',
|
||||||
|
inclusive: true,
|
||||||
|
}).range(first.to, last.from)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(last.from, last.to)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline code: `code` ─────────────────────────────────────────
|
||||||
|
case 'InlineCode': {
|
||||||
|
if (shouldDecorate(state, nFrom, nTo)) {
|
||||||
|
const marks = nodeRef.node.getChildren('CodeMark')
|
||||||
|
const first = marks[0]
|
||||||
|
const last = marks[marks.length - 1]
|
||||||
|
if (first && last && first !== last) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(first.from, first.to)
|
||||||
|
)
|
||||||
|
if (last.from > first.to) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: 'ol-cm-md-inline-code',
|
||||||
|
}).range(first.to, last.from)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(last.from, last.to)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Decoration.set(decorations, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousTree = syntaxTree(view.state)
|
||||||
|
|
||||||
|
return {
|
||||||
|
decorations: createDecorations(view.state, previousTree),
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const tree = syntaxTree(update.state)
|
||||||
|
if (
|
||||||
|
tree.type === previousTree.type &&
|
||||||
|
tree.length < update.view.viewport.to
|
||||||
|
) {
|
||||||
|
this.decorations = this.decorations.map(update.changes)
|
||||||
|
} else if (
|
||||||
|
tree !== previousTree ||
|
||||||
|
update.viewportChanged ||
|
||||||
|
update.selectionSet
|
||||||
|
) {
|
||||||
|
previousTree = tree
|
||||||
|
this.decorations = createDecorations(update.state, tree)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations(value) {
|
||||||
|
return value.decorations
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
} from '@codemirror/view'
|
||||||
|
import { EditorState, Range } from '@codemirror/state'
|
||||||
|
import { syntaxTree } from '@codemirror/language'
|
||||||
|
import { Tree } from '@lezer/common'
|
||||||
|
import { selectionIntersects } from './selection'
|
||||||
|
|
||||||
|
function shouldDecorate(
|
||||||
|
state: EditorState,
|
||||||
|
from: number,
|
||||||
|
to: number
|
||||||
|
): boolean {
|
||||||
|
return state.readOnly || !selectionIntersects(state.selection, { from, to })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const typstDecorations = ViewPlugin.define(
|
||||||
|
view => {
|
||||||
|
const createDecorations = (
|
||||||
|
state: EditorState,
|
||||||
|
tree: Tree
|
||||||
|
): DecorationSet => {
|
||||||
|
const decorations: Range<Decoration>[] = []
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
tree.iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter(nodeRef) {
|
||||||
|
const { from: nFrom, to: nTo } = nodeRef
|
||||||
|
|
||||||
|
switch (nodeRef.type.name) {
|
||||||
|
case 'Strong': {
|
||||||
|
// *bold text*
|
||||||
|
if (shouldDecorate(state, nFrom, nTo)) {
|
||||||
|
decorations.push(Decoration.replace({}).range(nFrom, nFrom + 1))
|
||||||
|
const body = nodeRef.node.getChild('StrongBody')
|
||||||
|
if (body) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: 'ol-cm-typst-strong',
|
||||||
|
inclusive: true,
|
||||||
|
}).range(body.from, body.to)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (nTo > nFrom + 1) {
|
||||||
|
decorations.push(Decoration.replace({}).range(nTo - 1, nTo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Emphasis': {
|
||||||
|
// _italic text_
|
||||||
|
if (shouldDecorate(state, nFrom, nTo)) {
|
||||||
|
decorations.push(Decoration.replace({}).range(nFrom, nFrom + 1))
|
||||||
|
const body = nodeRef.node.getChild('EmphBody')
|
||||||
|
if (body) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: 'ol-cm-typst-emph',
|
||||||
|
inclusive: true,
|
||||||
|
}).range(body.from, body.to)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (nTo > nFrom + 1) {
|
||||||
|
decorations.push(Decoration.replace({}).range(nTo - 1, nTo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Heading': {
|
||||||
|
// = Title / == Title / === Title …
|
||||||
|
const markNode = nodeRef.node.getChild('HeadingMark')
|
||||||
|
const titleNode = nodeRef.node.getChild('HeadingTitle')
|
||||||
|
if (markNode && titleNode) {
|
||||||
|
const markText = state.doc.sliceString(
|
||||||
|
markNode.from,
|
||||||
|
markNode.to
|
||||||
|
)
|
||||||
|
const level = (markText.match(/^=+/) ?? ['='])[0].length
|
||||||
|
const cls = `ol-cm-typst-heading ol-cm-typst-h${Math.min(level, 6)}`
|
||||||
|
|
||||||
|
if (shouldDecorate(state, markNode.from, markNode.to)) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(markNode.from, markNode.to)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({ class: cls }).range(
|
||||||
|
titleNode.from,
|
||||||
|
titleNode.to
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'RawInline': {
|
||||||
|
// `inline code`
|
||||||
|
if (shouldDecorate(state, nFrom, nTo)) {
|
||||||
|
const content = nodeRef.node.getChild('RawInlineContent')
|
||||||
|
if (content) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(nFrom, content.from)
|
||||||
|
)
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: 'ol-cm-typst-raw-inline',
|
||||||
|
}).range(content.from, content.to)
|
||||||
|
)
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(content.to, nTo)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Decoration.set(decorations, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousTree = syntaxTree(view.state)
|
||||||
|
|
||||||
|
return {
|
||||||
|
decorations: createDecorations(view.state, previousTree),
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const tree = syntaxTree(update.state)
|
||||||
|
if (
|
||||||
|
tree.type === previousTree.type &&
|
||||||
|
tree.length < update.view.viewport.to
|
||||||
|
) {
|
||||||
|
// still parsing — just map existing decorations over any edits
|
||||||
|
this.decorations = this.decorations.map(update.changes)
|
||||||
|
} else if (
|
||||||
|
tree !== previousTree ||
|
||||||
|
update.viewportChanged ||
|
||||||
|
update.selectionSet // ← re-evaluate on cursor movement to show/hide markers
|
||||||
|
) {
|
||||||
|
previousTree = tree
|
||||||
|
this.decorations = createDecorations(update.state, tree)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations(value) {
|
||||||
|
return value.decorations
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -467,6 +467,63 @@ const mainVisualTheme = EditorView.theme({
|
|||||||
padding: '0 0.5em',
|
padding: '0 0.5em',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Quarto / Markdown visual decorations ─────────────────────────────────
|
||||||
|
'.ol-cm-md-strong': {
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
'.ol-cm-md-emph': {
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
'.ol-cm-md-strikethrough': {
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
},
|
||||||
|
'.ol-cm-md-inline-code': {
|
||||||
|
fontFamily: 'var(--source-font-family)',
|
||||||
|
fontSize: '0.9em',
|
||||||
|
backgroundColor: 'rgba(125, 125, 125, 0.1)',
|
||||||
|
borderRadius: '3px',
|
||||||
|
lineHeight: 1,
|
||||||
|
},
|
||||||
|
'.ol-cm-md-heading': {
|
||||||
|
fontWeight: 550,
|
||||||
|
lineHeight: '1.35',
|
||||||
|
color: 'inherit !important',
|
||||||
|
background: 'inherit !important',
|
||||||
|
},
|
||||||
|
'.ol-cm-md-h1': { fontSize: '2em' },
|
||||||
|
'.ol-cm-md-h2': { fontSize: '1.6em' },
|
||||||
|
'.ol-cm-md-h3': { fontSize: '1.44em' },
|
||||||
|
'.ol-cm-md-h4': { fontSize: '1.2em' },
|
||||||
|
'.ol-cm-md-h5': { fontSize: '1em' },
|
||||||
|
'.ol-cm-md-h6': { fontSize: '1em' },
|
||||||
|
|
||||||
|
// ── Typst visual decorations ──────────────────────────────────────────────
|
||||||
|
'.ol-cm-typst-strong': {
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
'.ol-cm-typst-emph': {
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
'.ol-cm-typst-heading': {
|
||||||
|
fontWeight: 550,
|
||||||
|
lineHeight: '1.35',
|
||||||
|
color: 'inherit !important',
|
||||||
|
background: 'inherit !important',
|
||||||
|
},
|
||||||
|
'.ol-cm-typst-h1': { fontSize: '2em' },
|
||||||
|
'.ol-cm-typst-h2': { fontSize: '1.6em' },
|
||||||
|
'.ol-cm-typst-h3': { fontSize: '1.44em' },
|
||||||
|
'.ol-cm-typst-h4': { fontSize: '1.2em' },
|
||||||
|
'.ol-cm-typst-h5': { fontSize: '1em' },
|
||||||
|
'.ol-cm-typst-h6': { fontSize: '1em' },
|
||||||
|
'.ol-cm-typst-raw-inline': {
|
||||||
|
fontFamily: 'var(--source-font-family)',
|
||||||
|
fontSize: '0.9em',
|
||||||
|
backgroundColor: 'rgba(125, 125, 125, 0.1)',
|
||||||
|
borderRadius: '3px',
|
||||||
|
lineHeight: 1,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const contentWidthThemeConf = new Compartment()
|
const contentWidthThemeConf = new Compartment()
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { listItemMarker } from './list-item-marker'
|
|||||||
import { pasteHtml } from './paste-html'
|
import { pasteHtml } from './paste-html'
|
||||||
import { commandTooltip } from '../command-tooltip'
|
import { commandTooltip } from '../command-tooltip'
|
||||||
import { tableGeneratorTheme } from './table-generator'
|
import { tableGeneratorTheme } from './table-generator'
|
||||||
|
import { typstDecorations } from './typst-decorations'
|
||||||
|
import { quartoDecorations } from './quarto-decorations'
|
||||||
import { debugConsole } from '@/utils/debugging'
|
import { debugConsole } from '@/utils/debugging'
|
||||||
import { PreviewPath } from '../../../../../../types/preview-path'
|
import { PreviewPath } from '../../../../../../types/preview-path'
|
||||||
|
|
||||||
@@ -183,6 +185,8 @@ const extension = (options: Options) => [
|
|||||||
atomicDecorations(options),
|
atomicDecorations(options),
|
||||||
visualEditorExtensions.map(extension => extension(options)),
|
visualEditorExtensions.map(extension => extension(options)),
|
||||||
markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets
|
markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets
|
||||||
|
typstDecorations,
|
||||||
|
quartoDecorations,
|
||||||
visualKeymap,
|
visualKeymap,
|
||||||
commandTooltip,
|
commandTooltip,
|
||||||
scrollJumpAdjuster,
|
scrollJumpAdjuster,
|
||||||
|
|||||||
+38
-17
@@ -23,32 +23,53 @@ const LEVELS: NestingLevel[] = [
|
|||||||
// after it, so this stays clear of code.
|
// after it, so this stays clear of code.
|
||||||
const HEADING_REGEX = /^(=+)[ \t]+(.*\S)[ \t]*$/
|
const HEADING_REGEX = /^(=+)[ \t]+(.*\S)[ \t]*$/
|
||||||
|
|
||||||
|
// Count unescaped '$' signs on a line to track math-mode parity.
|
||||||
|
function countDollars(text: string): number {
|
||||||
|
let count = 0
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] === '\\') { i++; continue }
|
||||||
|
if (text[i] === '$') count++
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
function computeOutline(
|
function computeOutline(
|
||||||
state: EditorState
|
state: EditorState
|
||||||
): ProjectionResult<FlatOutlineItem> {
|
): ProjectionResult<FlatOutlineItem> {
|
||||||
const items: FlatOutlineItem[] = []
|
const items: FlatOutlineItem[] = []
|
||||||
|
// Track whether we are inside a multi-line display math block.
|
||||||
|
// Each line with an odd number of unescaped '$' toggles the flag.
|
||||||
|
let inMath = false
|
||||||
|
|
||||||
for (let n = 1; n <= state.doc.lines; n++) {
|
for (let n = 1; n <= state.doc.lines; n++) {
|
||||||
const line = state.doc.line(n)
|
const line = state.doc.line(n)
|
||||||
const match = HEADING_REGEX.exec(line.text)
|
const text = line.text
|
||||||
if (!match) continue
|
|
||||||
|
|
||||||
const depth = match[1].length
|
// Only attempt heading detection when not inside a math block.
|
||||||
const level = LEVELS[Math.min(depth, LEVELS.length) - 1]
|
// (e.g. '= b+c$' on the second line of '$ a \n= b+c$' must be skipped.)
|
||||||
// Strip a trailing line comment, then a trailing label.
|
if (!inMath) {
|
||||||
const title = match[2]
|
const match = HEADING_REGEX.exec(text)
|
||||||
.replace(/\s*\/\/.*$/, '')
|
if (match) {
|
||||||
.replace(/\s*<[\w-]+>\s*$/, '')
|
const depth = match[1].length
|
||||||
.trim()
|
const level = LEVELS[Math.min(depth, LEVELS.length) - 1]
|
||||||
|
// Strip a trailing line comment, then a trailing label.
|
||||||
|
const title = match[2]
|
||||||
|
.replace(/\s*\/\/.*$/, '')
|
||||||
|
.replace(/\s*<[\w-]+>\s*$/, '')
|
||||||
|
.trim()
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
line: n,
|
line: n,
|
||||||
toLine: n,
|
toLine: n,
|
||||||
title,
|
title,
|
||||||
from: line.from,
|
from: line.from,
|
||||||
to: line.to,
|
to: line.to,
|
||||||
level,
|
level,
|
||||||
} as FlatOutlineItem)
|
} as FlatOutlineItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countDollars(text) % 2 === 1) inMath = !inMath
|
||||||
}
|
}
|
||||||
|
|
||||||
return { items, status: ProjectionStatus.Complete }
|
return { items, status: ProjectionStatus.Complete }
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import { styleTags, tags as t } from '@lezer/highlight'
|
|||||||
import { parser } from '../../lezer-typst/typst.mjs'
|
import { parser } from '../../lezer-typst/typst.mjs'
|
||||||
import { typstCompletions } from './complete'
|
import { typstCompletions } from './complete'
|
||||||
import { typstDocumentOutline } from './document-outline'
|
import { typstDocumentOutline } from './document-outline'
|
||||||
|
import { shortcuts } from './shortcuts'
|
||||||
|
|
||||||
// Note on tree structure: rules starting with a lowercase letter in the grammar
|
// Note on tree structure: rules starting with a lowercase letter in the grammar
|
||||||
// are inline (no tree node), so their children are promoted to the parent.
|
// are inline (no tree node), so their children are promoted to the parent.
|
||||||
// E.g. codeArgItem, codeValue, callSuffix, codeArgList are all inline.
|
// E.g. codeArgItem, codeValue, callSuffix, codeArgList are all inline.
|
||||||
// Therefore:
|
// Named arg keys emit CodeArgKey (not CodeIdent) via codeIdentTokenizer,
|
||||||
// - The named-argument key "CodeIdent" is a *direct* child of CodeArgs.
|
// so CodeArgKey appears at the same level as other codeArgItem children.
|
||||||
// - Positional arguments that are identifiers are wrapped in CallExpr.
|
|
||||||
|
|
||||||
export const TypstLanguage = LRLanguage.define({
|
export const TypstLanguage = LRLanguage.define({
|
||||||
name: 'typst',
|
name: 'typst',
|
||||||
@@ -50,11 +50,13 @@ export const TypstLanguage = LRLanguage.define({
|
|||||||
CodeBool: t.atom,
|
CodeBool: t.atom,
|
||||||
|
|
||||||
// Identifiers:
|
// Identifiers:
|
||||||
// - direct child of CallExpr → function/method name
|
// CodeExpr/CodeIdent — bare #func (no args) → function style
|
||||||
// - direct child of CodeArgs → named argument key (key: value syntax)
|
// FuncExpr/CodeIdent — func call with args/method (#func(...), link.with(url)) → function style
|
||||||
// - everywhere else → plain variable
|
// CodeArgKey — named arg key (tokenizer pre-disambiguates on ':') → attributeName
|
||||||
'CallExpr/CodeIdent': t.function(t.variableName),
|
// CodeIdent — plain variable/constant reference (e.g. 'left', 'center') → variable
|
||||||
'CodeArgs/CodeIdent': t.attributeName,
|
'CodeExpr/CodeIdent': t.function(t.variableName),
|
||||||
|
'FuncExpr/CodeIdent': t.function(t.variableName),
|
||||||
|
CodeArgKey: t.attributeName,
|
||||||
CodeIdent: t.variableName,
|
CodeIdent: t.variableName,
|
||||||
|
|
||||||
// Literals in code mode
|
// Literals in code mode
|
||||||
@@ -73,8 +75,11 @@ export const TypstLanguage = LRLanguage.define({
|
|||||||
MathContent: t.string,
|
MathContent: t.string,
|
||||||
|
|
||||||
// Markup emphasis
|
// Markup emphasis
|
||||||
'Strong/"*" Strong/StrongText': t.strong,
|
'Strong/"*" Strong/StrongBody': t.strong,
|
||||||
'Emphasis/"_" Emphasis/EmphText': t.emphasis,
|
'Emphasis/"_" Emphasis/EmphBody': t.emphasis,
|
||||||
|
|
||||||
|
// Bare URLs (https://... / http://...)
|
||||||
|
URL: t.string,
|
||||||
|
|
||||||
// Labels (<name>) and references (@name)
|
// Labels (<name>) and references (@name)
|
||||||
'Label/"<" Label/">" Label/LabelName': t.labelName,
|
'Label/"<" Label/">" Label/LabelName': t.labelName,
|
||||||
@@ -97,6 +102,9 @@ const typstHighlightStyle = HighlightStyle.define([
|
|||||||
{ tag: t.heading, fontWeight: 'bold' },
|
{ tag: t.heading, fontWeight: 'bold' },
|
||||||
{ tag: t.strong, fontWeight: 'bold' },
|
{ tag: t.strong, fontWeight: 'bold' },
|
||||||
{ tag: t.emphasis, fontStyle: 'italic' },
|
{ tag: t.emphasis, fontStyle: 'italic' },
|
||||||
|
// Named arg keys (fill:, caption:, columns:…) — amber colour that reads
|
||||||
|
// well on both light and dark backgrounds, independent of theme CSS.
|
||||||
|
{ tag: t.attributeName, color: '#c47900' },
|
||||||
])
|
])
|
||||||
|
|
||||||
export const typst = () => {
|
export const typst = () => {
|
||||||
@@ -104,5 +112,6 @@ export const typst = () => {
|
|||||||
TypstLanguage.data.of({ autocomplete: typstCompletions }),
|
TypstLanguage.data.of({ autocomplete: typstCompletions }),
|
||||||
typstDocumentOutline,
|
typstDocumentOutline,
|
||||||
syntaxHighlighting(typstHighlightStyle),
|
syntaxHighlighting(typstHighlightStyle),
|
||||||
|
shortcuts(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Prec } from '@codemirror/state'
|
||||||
|
import { keymap } from '@codemirror/view'
|
||||||
|
import { toggleTypstMarkup, wrapTypstLink } from '../../commands/typst-ranges'
|
||||||
|
|
||||||
|
export const shortcuts = () => {
|
||||||
|
return Prec.high(
|
||||||
|
keymap.of([
|
||||||
|
{
|
||||||
|
key: 'Ctrl-b',
|
||||||
|
mac: 'Mod-b',
|
||||||
|
preventDefault: true,
|
||||||
|
run: toggleTypstMarkup('*', 'Strong'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Ctrl-i',
|
||||||
|
mac: 'Mod-i',
|
||||||
|
preventDefault: true,
|
||||||
|
run: toggleTypstMarkup('_', 'Emphasis'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Ctrl-k',
|
||||||
|
mac: 'Mod-k',
|
||||||
|
preventDefault: true,
|
||||||
|
run: wrapTypstLink,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,22 +8,50 @@ import {
|
|||||||
RawBlockBody,
|
RawBlockBody,
|
||||||
RawBlockClose,
|
RawBlockClose,
|
||||||
RawInlineContent,
|
RawInlineContent,
|
||||||
CodeBlockBody,
|
|
||||||
BlockCommentBody,
|
BlockCommentBody,
|
||||||
LineCommentContent,
|
LineCommentContent,
|
||||||
MathContent,
|
MathContent,
|
||||||
|
CodeKeyword,
|
||||||
|
CodeIdent,
|
||||||
|
CodeArgKey,
|
||||||
|
StrongBody,
|
||||||
|
EmphBody,
|
||||||
} from './typst.terms.mjs'
|
} from './typst.terms.mjs'
|
||||||
|
|
||||||
const BACKTICK = 96 // `
|
const BACKTICK = 96 // `
|
||||||
const SLASH = 47 // /
|
const SLASH = 47 // /
|
||||||
const STAR = 42 // *
|
const STAR = 42 // *
|
||||||
const NEWLINE = 10 // \n
|
const NEWLINE = 10 // \n
|
||||||
const EQUALS = 61 // =
|
const EQUALS = 61 // =
|
||||||
const SPACE = 32 //
|
const SPACE = 32 //
|
||||||
const TAB = 9 // \t
|
const TAB = 9 // \t
|
||||||
const DOLLAR = 36 // $
|
const DOLLAR = 36 // $
|
||||||
const OPEN_BRACE = 123 // {
|
const OPEN_BRACE = 123 // {
|
||||||
const CLOSE_BRACE = 125 // }
|
const CLOSE_BRACE = 125 // }
|
||||||
|
const HASH = 35 // #
|
||||||
|
const UNDERSCORE = 95 // _
|
||||||
|
const DOT = 46 // .
|
||||||
|
const OPEN_PAREN = 40 // (
|
||||||
|
const COMMA = 44 // ,
|
||||||
|
const COLON = 58 // :
|
||||||
|
const SEMICOLON = 59 // ;
|
||||||
|
const OPEN_ANGLE = 60 // <
|
||||||
|
const CLOSE_ANGLE = 62 // >
|
||||||
|
const PLUS = 43 // +
|
||||||
|
|
||||||
|
const KEYWORDS = new Set([
|
||||||
|
'let', 'set', 'show', 'import', 'include',
|
||||||
|
'if', 'else', 'for', 'while', 'return',
|
||||||
|
'break', 'continue', 'in', 'as',
|
||||||
|
'and', 'or', 'not', 'context',
|
||||||
|
])
|
||||||
|
|
||||||
|
const BOOLS = new Set(['true', 'false', 'none', 'auto'])
|
||||||
|
|
||||||
|
const isAlpha = ch => (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122)
|
||||||
|
const isDigit = ch => ch >= 48 && ch <= 57
|
||||||
|
const isIdentHead = ch => isAlpha(ch) || ch === UNDERSCORE
|
||||||
|
const isIdentTail = ch => isAlpha(ch) || isDigit(ch) || ch === UNDERSCORE || ch === 45
|
||||||
|
|
||||||
// ── headingTokenizer ────────────────────────────────────────────────────
|
// ── headingTokenizer ────────────────────────────────────────────────────
|
||||||
// Emits HeadingMark — the "=+" prefix plus the trailing whitespace.
|
// Emits HeadingMark — the "=+" prefix plus the trailing whitespace.
|
||||||
@@ -62,6 +90,17 @@ export const headingTitleTokenizer = new ExternalTokenizer(
|
|||||||
while (input.next !== -1 && input.next !== NEWLINE) {
|
while (input.next !== -1 && input.next !== NEWLINE) {
|
||||||
if (input.next === SLASH &&
|
if (input.next === SLASH &&
|
||||||
(input.peek(1) === SLASH || input.peek(1) === STAR)) break
|
(input.peek(1) === SLASH || input.peek(1) === STAR)) break
|
||||||
|
// Stop before a trailing '<label>' so it is parsed as a Label node
|
||||||
|
// rather than being merged into the heading title text.
|
||||||
|
// Only stops when '<' is immediately followed by a valid label name and '>'.
|
||||||
|
if (input.next === OPEN_ANGLE) {
|
||||||
|
const ch = input.peek(1)
|
||||||
|
if (isAlpha(ch) || isDigit(ch) || ch === UNDERSCORE) {
|
||||||
|
let j = 2
|
||||||
|
while (isIdentTail(input.peek(j)) || input.peek(j) === DOT || input.peek(j) === COLON) j++
|
||||||
|
if (input.peek(j) === CLOSE_ANGLE) break
|
||||||
|
}
|
||||||
|
}
|
||||||
input.advance()
|
input.advance()
|
||||||
hasContent = true
|
hasContent = true
|
||||||
}
|
}
|
||||||
@@ -105,6 +144,20 @@ export const rawTokenizer = new ExternalTokenizer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stack.canShift(RawBlockBody)) {
|
if (stack.canShift(RawBlockBody)) {
|
||||||
|
// Guard: must genuinely follow a RawBlockOpen (which ends with \n).
|
||||||
|
// Walk backward past any lang tag (A-Za-z0-9) and require ```.
|
||||||
|
// This blocks spurious LALR-merged states from consuming body text.
|
||||||
|
if (input.peek(-1) !== NEWLINE) return
|
||||||
|
let back = -2
|
||||||
|
while (
|
||||||
|
(input.peek(back) >= 65 && input.peek(back) <= 90) ||
|
||||||
|
(input.peek(back) >= 97 && input.peek(back) <= 122) ||
|
||||||
|
(input.peek(back) >= 48 && input.peek(back) <= 57)
|
||||||
|
) { back-- }
|
||||||
|
if (input.peek(back) !== BACKTICK ||
|
||||||
|
input.peek(back - 1) !== BACKTICK ||
|
||||||
|
input.peek(back - 2) !== BACKTICK) return
|
||||||
|
|
||||||
let hasContent = false
|
let hasContent = false
|
||||||
while (input.next !== -1) {
|
while (input.next !== -1) {
|
||||||
if (
|
if (
|
||||||
@@ -136,36 +189,6 @@ export const rawInlineTokenizer = new ExternalTokenizer(
|
|||||||
{ contextual: false }
|
{ contextual: false }
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── codeBlockTokenizer ──────────────────────────────────────────────────
|
|
||||||
// Emits CodeBlockBody — the interior of a #{ ... } code block.
|
|
||||||
// Tracks brace nesting depth so that inner braces (e.g. #{ f({ x }) })
|
|
||||||
// are included in the body rather than closing the outer block.
|
|
||||||
export const codeBlockTokenizer = new ExternalTokenizer(
|
|
||||||
(input, _stack) => {
|
|
||||||
// The opening '{' has already been consumed by the grammar rule.
|
|
||||||
let depth = 1
|
|
||||||
let hasContent = false
|
|
||||||
while (input.next !== -1) {
|
|
||||||
const ch = input.next
|
|
||||||
if (ch === OPEN_BRACE) {
|
|
||||||
depth++
|
|
||||||
input.advance()
|
|
||||||
hasContent = true
|
|
||||||
} else if (ch === CLOSE_BRACE) {
|
|
||||||
if (depth === 1) break // leave this '}' for the grammar rule
|
|
||||||
depth--
|
|
||||||
input.advance()
|
|
||||||
hasContent = true
|
|
||||||
} else {
|
|
||||||
input.advance()
|
|
||||||
hasContent = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasContent) input.acceptToken(CodeBlockBody)
|
|
||||||
},
|
|
||||||
{ contextual: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── blockCommentTokenizer ───────────────────────────────────────────────
|
// ── blockCommentTokenizer ───────────────────────────────────────────────
|
||||||
// Emits BlockCommentBody — the interior of a /* ... */ comment.
|
// Emits BlockCommentBody — the interior of a /* ... */ comment.
|
||||||
// Typst supports nested block comments (/* /* inner */ outer */), so this
|
// Typst supports nested block comments (/* /* inner */ outer */), so this
|
||||||
@@ -215,9 +238,13 @@ export const lineCommentContentTokenizer = new ExternalTokenizer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ── mathContentTokenizer ────────────────────────────────────────────────
|
// ── mathContentTokenizer ────────────────────────────────────────────────
|
||||||
// Emits MathContent — everything between the $...$ delimiters (no newlines).
|
// Emits MathContent — one line of content between the $...$ delimiters.
|
||||||
// External rather than a @tokens rule for the same reason as LineCommentContent:
|
// Stops at '$' or '\n' so each token is bounded to a single line.
|
||||||
// ![$\n]+ overlaps with spaces, '<', '@', and other literals in merged states.
|
//
|
||||||
|
// The grammar uses MathContent* (not MathContent?) so multi-line display
|
||||||
|
// math ($ ... \n ... $) is handled by multiple MathContent tokens, one per
|
||||||
|
// line, with @skip consuming the newlines in between. This keeps each
|
||||||
|
// token short and prevents a stray '$' from consuming the whole document.
|
||||||
export const mathContentTokenizer = new ExternalTokenizer(
|
export const mathContentTokenizer = new ExternalTokenizer(
|
||||||
(input, _stack) => {
|
(input, _stack) => {
|
||||||
let hasContent = false
|
let hasContent = false
|
||||||
@@ -229,3 +256,174 @@ export const mathContentTokenizer = new ExternalTokenizer(
|
|||||||
},
|
},
|
||||||
{ contextual: false }
|
{ contextual: false }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── codeKeywordTokenizer ─────────────────────────────────────────────────
|
||||||
|
// Emits CodeKeyword (let, set, for, while, in, …) ONLY when the preceding
|
||||||
|
// character is '#', i.e. we are immediately after the '#' sigil in a CodeExpr.
|
||||||
|
//
|
||||||
|
// The peek(-1)==='#' guard is what prevents LALR state-merging from causing
|
||||||
|
// these tokens to fire in body-text positions. Common English words like
|
||||||
|
// "in", "for", "while", "return" appear in markup paragraphs; without the
|
||||||
|
// guard they would be highlighted as keywords due to LALR-merged states where
|
||||||
|
// CodeKeyword is technically in the valid set.
|
||||||
|
export const codeKeywordTokenizer = new ExternalTokenizer(
|
||||||
|
(input, stack) => {
|
||||||
|
if (!stack.canShift(CodeKeyword)) return
|
||||||
|
// Valid positions: after '#', ':', '{' (code block start), or ';'.
|
||||||
|
// Walk back past optional whitespace.
|
||||||
|
let back = -1
|
||||||
|
while (input.peek(back) === SPACE || input.peek(back) === TAB || input.peek(back) === NEWLINE) back--
|
||||||
|
const kwPrev = input.peek(back)
|
||||||
|
if (kwPrev !== HASH && kwPrev !== COLON && kwPrev !== OPEN_BRACE && kwPrev !== SEMICOLON) return
|
||||||
|
|
||||||
|
// Peek ahead to read the full identifier without advancing.
|
||||||
|
let len = 0
|
||||||
|
while (true) {
|
||||||
|
const ch = input.peek(len)
|
||||||
|
if (isIdentHead(ch) || (len > 0 && isIdentTail(ch))) { len++ } else { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (len === 0) return
|
||||||
|
|
||||||
|
const chars = []
|
||||||
|
for (let i = 0; i < len; i++) chars.push(input.peek(i))
|
||||||
|
const word = String.fromCharCode(...chars)
|
||||||
|
|
||||||
|
if (!KEYWORDS.has(word)) return
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i++) input.advance()
|
||||||
|
input.acceptToken(CodeKeyword)
|
||||||
|
},
|
||||||
|
{ contextual: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── codeIdentTokenizer ───────────────────────────────────────────────────
|
||||||
|
// Emits CodeIdent — identifier tokens inside code expressions (#ident,
|
||||||
|
// #func(args), #obj.method, etc.).
|
||||||
|
//
|
||||||
|
// Moving CodeIdent from @tokens to an external tokenizer allows a
|
||||||
|
// character-level guard: we only emit when the preceding non-whitespace
|
||||||
|
// character is one of '#', '.', '(', ',' — genuine code-context positions.
|
||||||
|
// This stops the token from firing in markup body text where LALR-merged
|
||||||
|
// states would otherwise cause '_italic_' to be consumed as one big
|
||||||
|
// CodeIdent (since '_' is a valid identHead) instead of opening Emphasis.
|
||||||
|
//
|
||||||
|
// Keywords and bools are excluded so codeKeywordTokenizer / CodeBool can
|
||||||
|
// handle them without conflict.
|
||||||
|
//
|
||||||
|
// The backward scan runs BEFORE any canShift gate. canShift(CodeArgKey) is
|
||||||
|
// unreliable (LALR state merging can suppress it even at genuine arg-key
|
||||||
|
// positions, e.g. 'caption:' after a complex nested call like 'table(...)').
|
||||||
|
// We derive couldBeArgKey from character-level evidence ('(' or ',') and use
|
||||||
|
// that to decide whether to continue even when canShift(CodeIdent) is false.
|
||||||
|
export const codeIdentTokenizer = new ExternalTokenizer(
|
||||||
|
(input, stack) => {
|
||||||
|
const couldBeIdent = stack.canShift(CodeIdent)
|
||||||
|
|
||||||
|
// Walk back past whitespace — primary context discriminator.
|
||||||
|
let back = -1
|
||||||
|
while (input.peek(back) === SPACE || input.peek(back) === TAB || input.peek(back) === NEWLINE) back--
|
||||||
|
const prev = input.peek(back)
|
||||||
|
|
||||||
|
if (prev !== HASH && prev !== DOT && prev !== OPEN_PAREN && prev !== COMMA && prev !== EQUALS && prev !== COLON && prev !== PLUS) {
|
||||||
|
if (!isIdentTail(prev)) {
|
||||||
|
// prev is a structural delimiter (e.g. ')' after a function call, '{' at
|
||||||
|
// block start, '}' after a nested block). These are valid statement-start
|
||||||
|
// positions inside a CodeBlock's codeStatement* list. Trust canShift —
|
||||||
|
// it's reliable in the grammar-parsed code-block states.
|
||||||
|
if (!couldBeIdent) return
|
||||||
|
} else {
|
||||||
|
// prev looks like the tail of a preceding word — scan back to find '#' or ':'.
|
||||||
|
// Accepting ':' lets multi-word chains like 'show sel: set text' work.
|
||||||
|
let b = back
|
||||||
|
while (isIdentTail(input.peek(b))) b--
|
||||||
|
while (input.peek(b) === SPACE || input.peek(b) === TAB || input.peek(b) === NEWLINE) b--
|
||||||
|
const chainEnd = input.peek(b)
|
||||||
|
if (chainEnd !== HASH && chainEnd !== COLON) {
|
||||||
|
// Could be second+ statement in a code block (e.g. after 'let x = 1').
|
||||||
|
if (!couldBeIdent) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In arg-delimiter positions ('(' or ',') we may emit CodeArgKey regardless
|
||||||
|
// of canShift(CodeIdent) — LALR merging can suppress canShift(CodeIdent)
|
||||||
|
// after a complex first argument (e.g. figure(table(...), caption: ...)).
|
||||||
|
// ':' and '=' are value positions, NOT arg-key positions.
|
||||||
|
const couldBeArgKey = prev === OPEN_PAREN || prev === COMMA
|
||||||
|
if (!couldBeIdent && !couldBeArgKey) return
|
||||||
|
|
||||||
|
// Must start with an identifier head character.
|
||||||
|
if (!isIdentHead(input.next)) return
|
||||||
|
|
||||||
|
// Peek ahead to read the full identifier.
|
||||||
|
let len = 0
|
||||||
|
while (true) {
|
||||||
|
const ch = input.peek(len)
|
||||||
|
if (len === 0 ? isIdentHead(ch) : isIdentTail(ch)) { len++ } else { break }
|
||||||
|
}
|
||||||
|
if (len === 0) return
|
||||||
|
|
||||||
|
const chars = []
|
||||||
|
for (let i = 0; i < len; i++) chars.push(input.peek(i))
|
||||||
|
const word = String.fromCharCode(...chars)
|
||||||
|
|
||||||
|
// Let codeKeywordTokenizer handle keywords; let CodeBool handle bools.
|
||||||
|
if (KEYWORDS.has(word) || BOOLS.has(word)) return
|
||||||
|
|
||||||
|
// Emit CodeArgKey when this identifier is immediately followed by ':'.
|
||||||
|
// Only applies in arg-delimiter positions (couldBeArgKey).
|
||||||
|
let isArgKey = false
|
||||||
|
if (couldBeArgKey) {
|
||||||
|
let afterLen = len
|
||||||
|
while (input.peek(afterLen) === SPACE || input.peek(afterLen) === TAB) afterLen++
|
||||||
|
isArgKey = (input.peek(afterLen) === COLON)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i++) input.advance()
|
||||||
|
if (isArgKey) {
|
||||||
|
input.acceptToken(CodeArgKey)
|
||||||
|
} else if (couldBeIdent) {
|
||||||
|
input.acceptToken(CodeIdent)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ contextual: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── strongBodyTokenizer ──────────────────────────────────────────────────
|
||||||
|
// Emits StrongBody — the content between the '*' delimiters of a Strong node.
|
||||||
|
//
|
||||||
|
// contextual: true — only fires when StrongBody is in the valid set, i.e.
|
||||||
|
// inside Strong → "*" . StrongBody? "*". This state is very specific and
|
||||||
|
// is not merged with item* by Lezer's aggressive LALR merging, so canShift
|
||||||
|
// is a reliable guard here.
|
||||||
|
//
|
||||||
|
// Reads everything up to the first '*' or newline (Typst bold does not span
|
||||||
|
// lines). A trailing '*' that is the closing delimiter is left for the
|
||||||
|
// grammar rule to consume.
|
||||||
|
export const strongBodyTokenizer = new ExternalTokenizer(
|
||||||
|
(input, _stack) => {
|
||||||
|
let hasContent = false
|
||||||
|
while (input.next !== -1 && input.next !== STAR && input.next !== NEWLINE) {
|
||||||
|
input.advance()
|
||||||
|
hasContent = true
|
||||||
|
}
|
||||||
|
if (hasContent) input.acceptToken(StrongBody)
|
||||||
|
},
|
||||||
|
{ contextual: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── emphBodyTokenizer ────────────────────────────────────────────────────
|
||||||
|
// Emits EmphBody — the content between the '_' delimiters of an Emphasis node.
|
||||||
|
// Same design as strongBodyTokenizer; stops at '_' or newline.
|
||||||
|
export const emphBodyTokenizer = new ExternalTokenizer(
|
||||||
|
(input, _stack) => {
|
||||||
|
let hasContent = false
|
||||||
|
while (input.next !== -1 && input.next !== UNDERSCORE && input.next !== NEWLINE) {
|
||||||
|
input.advance()
|
||||||
|
hasContent = true
|
||||||
|
}
|
||||||
|
if (hasContent) input.acceptToken(EmphBody)
|
||||||
|
},
|
||||||
|
{ contextual: true }
|
||||||
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user