Compare commits
124 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 |
@@ -329,6 +329,8 @@ jobs:
|
||||
# (CE default): admin creates accounts / sends invites.
|
||||
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
|
||||
value: "true"
|
||||
- name: OVERLEAF_LATEX_SHELL_ESCAPE
|
||||
value: "true"
|
||||
# (SMTP email vars are loaded below via envFrom.)
|
||||
# SMTP for password-reset / invite emails. All
|
||||
# OVERLEAF_EMAIL_* vars come from the optional 'verso-smtp'
|
||||
|
||||
@@ -300,6 +300,8 @@ jobs:
|
||||
# link-sharing users).
|
||||
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
|
||||
value: "true"
|
||||
- name: OVERLEAF_LATEX_SHELL_ESCAPE
|
||||
value: "true"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
||||
@@ -2,73 +2,182 @@
|
||||
<img src="services/web/public/img/ol-brand/verso-logo.svg" alt="Verso" width="440">
|
||||
</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 |
|
||||
|-----------|----------|----------------|
|
||||
| `.qmd` | Quarto | PDF (via Typst or LaTeX), or an HTML/RevealJS deck |
|
||||
| `.tex` | `latexmk` / TeX Live | PDF |
|
||||
| `.typ` | Typst | PDF |
|
||||
## What is Verso?
|
||||
|
||||
All three coexist on one server; no per-project configuration is required to
|
||||
pick the engine.
|
||||
Verso is a fork of [Overleaf](https://github.com/overleaf/overleaf) that extends its
|
||||
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
|
||||
|
||||
- **Real-time collaboration** — multiple people editing the same file at once,
|
||||
powered by Overleaf's operational-transformation engine, with live cursors
|
||||
and full project history.
|
||||
- **Three compilers, auto-dispatched** — Quarto, LaTeX and Typst projects live
|
||||
side by side; the runner is selected from the root file's extension.
|
||||
- **Language-aware editor for all three**:
|
||||
- *LaTeX* — syntax highlighting, command/environment/reference autocomplete,
|
||||
linting (inherited from Overleaf).
|
||||
- *Quarto (`.qmd`)* — Markdown highlighting plus Quarto-aware completions:
|
||||
code chunks (```` ```{python} ````, `{r}`, `{julia}`, `{ojs}`…), callouts
|
||||
and fenced divs (`::: {.callout-note}`, columns, tabsets) and
|
||||
cross-references (`@fig-`, `@tbl-`, `@sec-`, `@eq-`).
|
||||
- *Typst (`.typ`)* — syntax highlighting and completions for the common
|
||||
functions and markup (`#import`, `#let`, `#set`, `#show`, `#figure`,
|
||||
`#table`, `#cite`, …).
|
||||
- **Document outline** — section headings are extracted into the sidebar
|
||||
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.
|
||||
- **Real-time collaboration** — multiple editors, live cursors, full project history and version restore.
|
||||
- **Three compilers, auto-dispatched** by root file extension:
|
||||
| Root file | Compiler | Typical output |
|
||||
|-----------|----------|----------------|
|
||||
| `.qmd` | Quarto | PDF (via Typst or LaTeX), HTML, or RevealJS |
|
||||
| `.tex` | `latexmk` / TeX Live | PDF |
|
||||
| `.typ` | Typst | PDF |
|
||||
- **Language-aware editor for all three** — syntax highlighting, completions, and a document outline panel for LaTeX, Quarto and Typst.
|
||||
- **Format badge** on the project dashboard; compiler dropdown greys out inapplicable engines.
|
||||
- **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.
|
||||
- **RevealJS thumbnails** — the first slide of a presentation is rendered as a preview card in the project list.
|
||||
- **Quarto Python cells** — optional per-project virtual environment built from `requirements.txt`, so Python code chunks execute during render.
|
||||
- **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.
|
||||
- **Lumière theme** — card-based project dashboard with PDF/slide thumbnails, a teal gradient identity, dark editor chrome, and an XS compact list view.
|
||||
- **i18n** — French, German, Italian and Spanish UI translations.
|
||||
- **Auto-compile** — preview refreshes automatically after you stop typing.
|
||||
|
||||
## Output formats
|
||||
---
|
||||
|
||||
In the YAML frontmatter of a `.qmd` file:
|
||||
## Releases
|
||||
|
||||
```yaml
|
||||
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
|
||||
```
|
||||
### Alpha 1
|
||||
|
||||
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
|
||||
> display-math blocks can trigger YAML parse errors in some Quarto versions.
|
||||
- Quarto (`.qmd`) and Typst (`.typ`) compilers running alongside TeX Live,
|
||||
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
|
||||
|
||||
@@ -82,24 +191,23 @@ docker run -d \
|
||||
registry.alocoq.fr/verso:latest
|
||||
```
|
||||
|
||||
Open `http://localhost` in your browser, then visit `/launchpad` on first run to
|
||||
create the admin account.
|
||||
Open `http://localhost`, then visit `/launchpad` on first run to create the admin
|
||||
account.
|
||||
|
||||
### Build from source
|
||||
|
||||
```bash
|
||||
# Build the base image (system deps + Quarto + TeX Live)
|
||||
cd server-ce
|
||||
make build-base
|
||||
|
||||
# Build the application image
|
||||
make build-community
|
||||
make build-base # base OS image: system deps, Quarto, Typst, TeX Live
|
||||
make build-community # application image: Node services + compiled frontend
|
||||
```
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `server-ce/Dockerfile-base` | Base OS image — system deps, Quarto (with Typst) and a TeX Live (`latexmk`) toolchain |
|
||||
| `server-ce/Dockerfile` | Application image — Node services and the compiled frontend |
|
||||
| `server-ce/Dockerfile-base` | Base image — system deps, Quarto (with Typst) and TeX Live |
|
||||
| `server-ce/Dockerfile` | App image — Node services and the compiled React frontend |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -108,10 +216,10 @@ single container managed by `runit`, with `nginx` as the front router.
|
||||
|
||||
```
|
||||
browser ──→ nginx:80
|
||||
├── / ──────────────────→ web:4000 (main app, React UI)
|
||||
├── /socket.io ──────────→ real-time:3026 (WebSocket, OT engine)
|
||||
├── /p/:token ───────────→ web (published output)
|
||||
└── /project/*/output/* → clsi-nginx:8080 (compiled output files)
|
||||
├── / ──────────────────→ web:4000 (main app, React UI)
|
||||
├── /socket.io ──────────→ real-time:3026 (WebSocket, OT engine)
|
||||
├── /p/:token ───────────→ web (published output)
|
||||
└── /project/*/output/* → clsi-nginx:8080 (compiled output files)
|
||||
|
||||
web → document-updater → Redis pub/sub → real-time → 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 |
|
||||
| `real-time` | WebSocket layer, live cursors and edit sync |
|
||||
| `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) |
|
||||
| `filestore` | Binary file storage (S3 or local) |
|
||||
| `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
|
||||
|
||||
@@ -195,39 +251,46 @@ The most commonly needed:
|
||||
| `OVERLEAF_SITE_URL` | — | Public URL (used in emails and published links) |
|
||||
| `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_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)
|
||||
for the full list.
|
||||
|
||||
---
|
||||
|
||||
## Relation to 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
|
||||
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).
|
||||
Verso is not affiliated with Overleaf Ltd.
|
||||
|
||||
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 repository](https://git.alocoq.fr/alois/verso). The upstream Overleaf
|
||||
contribution guidelines are in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
Verso is not accepting contributions or donations at this time. If you find it
|
||||
useful and want to support the broader ecosystem it builds on:
|
||||
|
||||
- **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
|
||||
|
||||
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.
|
||||
|
||||
@@ -54,6 +54,13 @@ RUN --mount=type=cache,target=/root/.cache \
|
||||
# Add the actual source files
|
||||
# ---------------------------
|
||||
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 \
|
||||
--mount=type=cache,target=/root/.yarn/berry/cache,id=server-ce-yarn-cache \
|
||||
--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 \
|
||||
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 \
|
||||
pandoc \
|
||||
# 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 \
|
||||
# install Node.js https://github.com/nodesource/distributions#nodejs
|
||||
@@ -104,6 +105,15 @@ RUN apt-get update \
|
||||
opencv-python-headless tqdm \
|
||||
&& 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)
|
||||
# -----------------------------------------------------------------------
|
||||
# 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 CLSI_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 = {
|
||||
clsi: {
|
||||
optimiseInDocker: process.env.OPTIMISE_PDF === 'true',
|
||||
latexShellEscape: process.env.OVERLEAF_LATEX_SHELL_ESCAPE === 'true',
|
||||
},
|
||||
|
||||
brandPrefix: '',
|
||||
|
||||
@@ -48,9 +48,9 @@ server {
|
||||
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/;
|
||||
}
|
||||
# handle output files for anonymous users
|
||||
location ~ ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ {
|
||||
rewrite ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ /$3 break;
|
||||
# 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/(.+)$ {
|
||||
rewrite ^/project/([0-9a-f-]+)/build/([0-9a-f-]+)/output/(.+)$ /$3 break;
|
||||
root /var/lib/overleaf/data/output/$1/generated-files/$2/;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ http {
|
||||
gzip_proxied any; # allow upstream server to compress.
|
||||
|
||||
client_max_body_size 500m;
|
||||
client_body_timeout 15m;
|
||||
|
||||
# gzip_vary on;
|
||||
# gzip_proxied any;
|
||||
|
||||
@@ -9,6 +9,26 @@ server {
|
||||
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 / {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -150,6 +150,10 @@ app.post(
|
||||
FileUploadMiddleware.multerMiddleware,
|
||||
ConversionController.convertPDFToJPEG
|
||||
)
|
||||
app.get(
|
||||
'/project/:project_id/user/:user_id/build/:build_id/thumbnail',
|
||||
ConversionController.thumbnailFromBuild
|
||||
)
|
||||
|
||||
if (process.env.NODE_ENV === 'development' && global.__coverage__) {
|
||||
app.get('/coverage', (req, res) => {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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 { expressify } from '@overleaf/promise-utils'
|
||||
import fs from 'node:fs/promises'
|
||||
@@ -16,10 +19,14 @@ import Settings from '@overleaf/settings'
|
||||
import Path from 'node:path'
|
||||
import { z } from '@overleaf/validation-tools'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const CONVERSION_CONFIGS = {
|
||||
docx: { extension: 'docx' },
|
||||
markdown: { extension: 'zip' },
|
||||
html: { extension: 'zip' },
|
||||
typst: { extension: 'typ' },
|
||||
latex: { extension: 'tex' },
|
||||
}
|
||||
|
||||
async function convertDocumentToLaTeX(req, res) {
|
||||
@@ -29,7 +36,7 @@ async function convertDocumentToLaTeX(req, res) {
|
||||
await fs.unlink(path).catch(() => {})
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
if (!conversionType || !['docx', 'markdown'].includes(conversionType)) {
|
||||
if (!conversionType || !['docx', 'markdown', 'typst'].includes(conversionType)) {
|
||||
await fs.unlink(path).catch(() => {})
|
||||
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 {
|
||||
convertDocumentToLaTeX: expressify(convertDocumentToLaTeX),
|
||||
convertProjectToDocument: expressify(convertProjectToDocument),
|
||||
convertPDFToJPEG: expressify(convertPDFToJPEG),
|
||||
thumbnailFromBuild: expressify(thumbnailFromBuild),
|
||||
}
|
||||
|
||||
@@ -16,11 +16,15 @@ const CONVERSION_CONFIGS = {
|
||||
inputFilename: 'input.md',
|
||||
pandocArgs: ['--from', 'markdown'],
|
||||
},
|
||||
typst: {
|
||||
inputFilename: 'input.typ',
|
||||
pandocArgs: ['--from', 'typst'],
|
||||
},
|
||||
}
|
||||
|
||||
const PDF_TO_JPEG_CONFIGS = {
|
||||
preview: { width: 794, quality: 90 },
|
||||
thumbnail: { width: 190, quality: 50 },
|
||||
thumbnail: { width: 794, quality: 90 },
|
||||
}
|
||||
|
||||
const PDF_TO_JPEG_INPUT_FILENAME = 'input.pdf'
|
||||
@@ -175,6 +179,31 @@ const LATEX_EXPORT_CONFIGS = {
|
||||
'--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(
|
||||
|
||||
@@ -197,6 +197,10 @@ function _buildLatexCommand(mainFile, opts = {}) {
|
||||
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
|
||||
// (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
|
||||
|
||||
@@ -26,6 +26,7 @@ import { expressify, promisify } from '@overleaf/promise-utils'
|
||||
import { handleAuthenticateErrors } from './AuthenticationErrors.mjs'
|
||||
import EmailHelper from '../Helpers/EmailHelper.mjs'
|
||||
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
|
||||
import Translations from '../../infrastructure/Translations.mjs'
|
||||
|
||||
const { hasAdminAccess } = AdminAuthorizationHelper
|
||||
|
||||
@@ -221,6 +222,17 @@ const AuthenticationController = {
|
||||
|
||||
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)
|
||||
AnalyticsRegistrationSourceHelper.clearSource(req.session)
|
||||
AnalyticsRegistrationSourceHelper.clearInbound(req.session)
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
fetchStreamWithResponse,
|
||||
RequestFailedError,
|
||||
} from '@overleaf/fetch-utils'
|
||||
import ThumbnailManager from './ThumbnailManager.mjs'
|
||||
import Features from '../../infrastructure/Features.mjs'
|
||||
import ClsiCacheController from './ClsiCacheController.mjs'
|
||||
import { prepareZipAttachment } from '../../infrastructure/Response.mjs'
|
||||
@@ -321,6 +322,10 @@ const _CompileController = {
|
||||
.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({
|
||||
@@ -346,6 +351,15 @@ const _CompileController = {
|
||||
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
|
||||
async compileSubmission(req, res) {
|
||||
res.setTimeout(COMPILE_TIMEOUT_MS)
|
||||
@@ -822,6 +836,7 @@ const CompileController = {
|
||||
proxySyncPdf: expressify(_CompileController.proxySyncPdf),
|
||||
proxySyncCode: expressify(_CompileController.proxySyncCode),
|
||||
wordCount: expressify(_CompileController.wordCount),
|
||||
getProjectThumbnail: expressify(_CompileController.getProjectThumbnail),
|
||||
|
||||
_getSafeProjectName: _CompileController._getSafeProjectName,
|
||||
_getSplitTestOptions,
|
||||
|
||||
@@ -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 SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
|
||||
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
|
||||
|
||||
@@ -20,6 +29,8 @@ const SUPPORTED_CONVERSION_TYPES = new Map([
|
||||
['docx', 'docx'],
|
||||
['markdown', 'zip'],
|
||||
['html', 'zip'],
|
||||
['typst', 'typ'],
|
||||
['latex', 'tex'],
|
||||
])
|
||||
|
||||
const exportProjectConversionSchema = z.object({
|
||||
@@ -99,14 +110,14 @@ async function exportProjectConversion(req, res) {
|
||||
{ compileFromHistory, rootResourcePath }
|
||||
)
|
||||
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', {
|
||||
sourceFormat: 'latex',
|
||||
sourceFormat: type === 'latex' ? 'typst' : 'latex',
|
||||
targetFormat: type,
|
||||
status: 'success',
|
||||
operation: 'export',
|
||||
})
|
||||
} catch (error) {
|
||||
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', {
|
||||
sourceFormat: 'latex',
|
||||
sourceFormat: type === 'latex' ? 'typst' : 'latex',
|
||||
targetFormat: type,
|
||||
status: 'failure',
|
||||
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 {
|
||||
exportProjectConversion: expressify(exportProjectConversion),
|
||||
downloadPreparedProjectExport: expressify(downloadPreparedProjectExport),
|
||||
convertDocInProject: expressify(convertDocInProject),
|
||||
|
||||
downloadProject(req, res, next) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
|
||||
@@ -9,7 +9,7 @@ export default _.template(`\
|
||||
<tr style="padding: 0; text-align: left; vertical-align: top;">
|
||||
<th style="margin: 0; padding: 0; text-align: left;">
|
||||
<% 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 %>
|
||||
</h3>
|
||||
<% } %>
|
||||
@@ -25,7 +25,7 @@ export default _.template(`\
|
||||
<% } %>
|
||||
|
||||
<% (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 %>
|
||||
</p>
|
||||
<% }) %>
|
||||
|
||||
@@ -15,7 +15,7 @@ export default _.template(`\
|
||||
<% } %>
|
||||
|
||||
<% (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 %>
|
||||
</p>
|
||||
<% }) %>
|
||||
|
||||
@@ -9,7 +9,7 @@ export default _.template(`\
|
||||
<tr style="padding: 0; text-align: left; vertical-align: top;">
|
||||
<th style="margin: 0; padding: 0; text-align: left;">
|
||||
<% 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 %>
|
||||
</h3>
|
||||
<% } %>
|
||||
@@ -25,7 +25,7 @@ export default _.template(`\
|
||||
<% } %>
|
||||
|
||||
<% (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 %>
|
||||
</p>
|
||||
<% }) %>
|
||||
@@ -52,7 +52,7 @@ export default _.template(`\
|
||||
<p style="margin: 0; padding: 0;"> </p>
|
||||
|
||||
<% (secondaryMessage).forEach(function(paragraph) { %>
|
||||
<p class="force-overleaf-style">
|
||||
<p class="force-verso-style">
|
||||
<%= paragraph %>
|
||||
</p>
|
||||
<% }) %>
|
||||
@@ -60,11 +60,11 @@ export default _.template(`\
|
||||
|
||||
<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:
|
||||
</p>
|
||||
|
||||
<p class="force-overleaf-style" style="font-size: 12px;">
|
||||
<p class="force-verso-style" style="font-size: 12px;">
|
||||
<%= ctaURL %>
|
||||
</p>
|
||||
</td>
|
||||
|
||||
@@ -15,7 +15,7 @@ export default _.template(`\
|
||||
<% } %>
|
||||
|
||||
<% (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 %>
|
||||
</p>
|
||||
<% }) %>
|
||||
@@ -32,7 +32,7 @@ export default _.template(`\
|
||||
|
||||
<% if (secondaryMessage && secondaryMessage.length > 0) { %>
|
||||
<% (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 %>
|
||||
</p>
|
||||
<% }) %>
|
||||
|
||||
@@ -276,7 +276,7 @@ templates.confirmCode = NoCTAEmailTemplate({
|
||||
return ''
|
||||
},
|
||||
subject(opts) {
|
||||
return `Confirm your email address on Overleaf (${opts.confirmCode})`
|
||||
return `Confirm your email address on ${settings.appName} (${opts.confirmCode})`
|
||||
},
|
||||
title(opts) {
|
||||
return 'Confirm your email address'
|
||||
@@ -284,7 +284,7 @@ templates.confirmCode = NoCTAEmailTemplate({
|
||||
message(opts, isPlainText) {
|
||||
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 code to confirm your email address.']
|
||||
@@ -477,7 +477,7 @@ templates.inviteNewUserToJoinManagedUsers = ctaTemplate({
|
||||
templates.groupSSOLinkingInvite = ctaTemplate({
|
||||
subject(opts) {
|
||||
const subjectPrefix = opts.reminder ? 'Reminder: ' : 'Action required: '
|
||||
return `${subjectPrefix}Authenticate your Overleaf account`
|
||||
return `${subjectPrefix}Authenticate your ${settings.appName} account`
|
||||
},
|
||||
title(opts) {
|
||||
const titlePrefix = opts.reminder ? 'Reminder: ' : ''
|
||||
@@ -495,8 +495,8 @@ templates.groupSSOLinkingInvite = ctaTemplate({
|
||||
</div>
|
||||
</br>
|
||||
<div>
|
||||
You won't need to remember a separate email address and password to sign in to Overleaf.
|
||||
All you need to do is authenticate your existing Overleaf account with your SSO provider.
|
||||
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 ${settings.appName} account with your SSO provider.
|
||||
</div>
|
||||
`,
|
||||
]
|
||||
@@ -517,7 +517,7 @@ templates.groupSSOLinkingInvite = ctaTemplate({
|
||||
|
||||
templates.groupSSOReauthenticate = ctaTemplate({
|
||||
subject(opts) {
|
||||
return 'Action required: Reauthenticate your Overleaf account'
|
||||
return `Action required: Reauthenticate your ${settings.appName} account`
|
||||
},
|
||||
title(opts) {
|
||||
return 'Action required: Reauthenticate SSO'
|
||||
@@ -526,8 +526,8 @@ templates.groupSSOReauthenticate = ctaTemplate({
|
||||
return [
|
||||
`Hi,
|
||||
<div>
|
||||
Single sign-on for your Overleaf group has been updated.
|
||||
This means you need to reauthenticate your Overleaf account with your group’s SSO provider.
|
||||
Single sign-on for your ${settings.appName} group has been updated.
|
||||
This means you need to reauthenticate your ${settings.appName} account with your group’s SSO provider.
|
||||
</div>
|
||||
`,
|
||||
]
|
||||
@@ -538,7 +538,7 @@ templates.groupSSOReauthenticate = ctaTemplate({
|
||||
} else {
|
||||
const passwordResetUrl = `${settings.siteUrl}/user/password/reset`
|
||||
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({
|
||||
subject(opts) {
|
||||
if (opts.userIsManaged) {
|
||||
return `Action required: Set your Overleaf password`
|
||||
return `Action required: Set your ${settings.appName} password`
|
||||
} else {
|
||||
return 'A change to your Overleaf login options'
|
||||
return `A change to your ${settings.appName} login options`
|
||||
}
|
||||
},
|
||||
title(opts) {
|
||||
@@ -567,12 +567,12 @@ templates.groupSSODisabled = ctaTemplate({
|
||||
message(opts, isPlainText) {
|
||||
const loginUrl = `${settings.siteUrl}/login`
|
||||
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 (opts.userIsManaged) {
|
||||
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(
|
||||
'user account management',
|
||||
`${settings.siteUrl}/learn/how-to/Understanding_Managed_Overleaf_Accounts`,
|
||||
`${settings.siteUrl}/learn/how-to/Understanding_Managed_Accounts`,
|
||||
isPlainText
|
||||
)
|
||||
|
||||
@@ -757,22 +757,17 @@ templates.userOnboardingEmail = NoCTAEmailTemplate({
|
||||
message(opts, isPlainText) {
|
||||
const learnLatexLink = EmailMessageHelper.displayLink(
|
||||
'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
|
||||
)
|
||||
const templatesLinks = EmailMessageHelper.displayLink(
|
||||
'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
|
||||
)
|
||||
const collaboratorsLink = EmailMessageHelper.displayLink(
|
||||
'Work with your collaborators',
|
||||
`${settings.siteUrl}/learn/how-to/Sharing_a_project?utm_source=overleaf&utm_medium=email&utm_campaign=onboarding`,
|
||||
isPlainText
|
||||
)
|
||||
const siteLink = EmailMessageHelper.displayLink(
|
||||
'www.overleaf.com',
|
||||
settings.siteUrl,
|
||||
`${settings.siteUrl}/learn/how-to/Sharing_a_project?utm_source=verso&utm_medium=email&utm_campaign=onboarding`,
|
||||
isPlainText
|
||||
)
|
||||
const userSettingsLink = EmailMessageHelper.displayLink(
|
||||
@@ -780,28 +775,21 @@ templates.userOnboardingEmail = NoCTAEmailTemplate({
|
||||
`${settings.siteUrl}/user/email-preferences`,
|
||||
isPlainText
|
||||
)
|
||||
const onboardingSurveyLink = EmailMessageHelper.displayLink(
|
||||
'Join our user feedback program',
|
||||
'https://forms.gle/DB7pdk2B1VFQqVVB9',
|
||||
isPlainText
|
||||
)
|
||||
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:`,
|
||||
`${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.`,
|
||||
`${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.`,
|
||||
`${onboardingSurveyLink} to help us make Overleaf even better!`,
|
||||
'Thanks again for using Overleaf :)',
|
||||
`Lee`,
|
||||
`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}.`,
|
||||
`${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.`,
|
||||
`Thanks for using ${settings.appName}!`,
|
||||
`The ${settings.appName} Team<hr>`,
|
||||
`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}.`,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
templates.securityAlert = NoCTAEmailTemplate({
|
||||
subject(opts) {
|
||||
return `Overleaf security note: ${opts.action}`
|
||||
return `${settings.appName} security note: ${opts.action}`
|
||||
},
|
||||
title(opts) {
|
||||
return opts.action.charAt(0).toUpperCase() + opts.action.slice(1)
|
||||
@@ -837,12 +825,11 @@ templates.securityAlert = NoCTAEmailTemplate({
|
||||
},
|
||||
})
|
||||
|
||||
const GIT_TOKEN_DOCS_URL =
|
||||
'https://docs.overleaf.com/integrations-and-add-ons/git-integration-and-github-synchronization/git-integration/git-integration-authentication-tokens#how-to-generate-authentication-tokens'
|
||||
const GIT_TOKEN_DOCS_URL = `${settings.siteUrl}/learn/how-to/Git_integration`
|
||||
|
||||
templates.gitTokenExpiringSoon = NoCTAEmailTemplate({
|
||||
subject() {
|
||||
return 'Your Overleaf token is about to expire'
|
||||
return `Your ${settings.appName} token is about to expire`
|
||||
},
|
||||
title() {
|
||||
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}.`,
|
||||
`Take a look at ${docsLink} if you need more help.`,
|
||||
'All the best,',
|
||||
'Team Overleaf',
|
||||
`The ${settings.appName} Team`,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
templates.gitTokenExpired = NoCTAEmailTemplate({
|
||||
subject() {
|
||||
return 'Your Overleaf token has expired'
|
||||
return `Your ${settings.appName} token has expired`
|
||||
},
|
||||
title() {
|
||||
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}.`,
|
||||
`Take a look at ${docsLink} if you need more help.`,
|
||||
'All the best,',
|
||||
'Team Overleaf',
|
||||
`The ${settings.appName} Team`,
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -1040,7 +1027,7 @@ templates.removeGroupMember = NoCTAEmailTemplate({
|
||||
|
||||
templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
|
||||
subject(opts) {
|
||||
return `Action required: Tax exemption verification for Overleaf [${opts.ein}]`
|
||||
return `Action required: Tax exemption verification for ${settings.appName} [${opts.ein}]`
|
||||
},
|
||||
title() {
|
||||
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.',
|
||||
'<br/>',
|
||||
'Best wishes,',
|
||||
'Team Overleaf',
|
||||
`The ${settings.appName} Team`,
|
||||
'<br/>',
|
||||
`Our reference: ${opts.stripeCustomerId}`,
|
||||
]
|
||||
@@ -1069,17 +1056,17 @@ templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
|
||||
|
||||
templates.groupMemberLimitWarning = ctaTemplate({
|
||||
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) {
|
||||
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) {
|
||||
return opts.firstName ? `Hi ${opts.firstName},` : 'Hi there,'
|
||||
},
|
||||
message(opts) {
|
||||
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>`,
|
||||
'Because domain capture is enabled, users from your domain can join automatically via SSO.' +
|
||||
'<br/>' +
|
||||
|
||||
@@ -60,8 +60,8 @@ export default _.template(`\
|
||||
.email-layout-table { border-collapse: collapse !important; }
|
||||
a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; }
|
||||
|
||||
.force-overleaf-style a,
|
||||
.force-overleaf-style a[href] {
|
||||
.force-verso-style a,
|
||||
.force-verso-style a[href] {
|
||||
color: ${colors.linkGreen} !important;
|
||||
text-decoration: underline !important;
|
||||
-moz-hyphens: none;
|
||||
@@ -69,10 +69,10 @@ export default _.template(`\
|
||||
-webkit-hyphens: none;
|
||||
hyphens: none;
|
||||
}
|
||||
.force-overleaf-style a:visited,
|
||||
.force-overleaf-style a[href]:visited { color: ${colors.linkGreen}; }
|
||||
.force-overleaf-style a:hover,
|
||||
.force-overleaf-style a[href]:hover { color: ${colors.linkHover}; }
|
||||
.force-verso-style a:visited,
|
||||
.force-verso-style a[href]:visited { color: ${colors.linkGreen}; }
|
||||
.force-verso-style a:hover,
|
||||
.force-verso-style a[href]:hover { color: ${colors.linkHover}; }
|
||||
|
||||
@media only screen and (min-width: 621px) {
|
||||
.email-card-inner { padding: 56px !important; }
|
||||
@@ -149,7 +149,7 @@ export default _.template(`\
|
||||
|
||||
<% if (footerMessage) { %>
|
||||
<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 %>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -4,9 +4,9 @@ export const colors = {
|
||||
textDark: '#1b222c',
|
||||
textMuted: '#495365',
|
||||
background: '#f4f5f6',
|
||||
ctaGreen: '#098842',
|
||||
ctaGreen: '#2a9d8f',
|
||||
ctaText: '#ffffff',
|
||||
linkGreen: '#1e6b41',
|
||||
linkHover: '#155a30',
|
||||
logoGreen: '#04652f',
|
||||
linkGreen: '#1d7a6e',
|
||||
linkHover: '#155e55',
|
||||
logoGreen: '#2a9d8f',
|
||||
}
|
||||
|
||||
@@ -1364,6 +1364,7 @@ const _ProjectController = {
|
||||
function getInitialLoadingScreenTheme(overallThemeSetting) {
|
||||
switch (overallThemeSetting) {
|
||||
case 'light-':
|
||||
case 'lumiere-':
|
||||
return 'light'
|
||||
case '':
|
||||
return 'dark'
|
||||
|
||||
@@ -51,24 +51,20 @@ async function setRootDocAutomatically(projectId) {
|
||||
}
|
||||
|
||||
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,
|
||||
followSymlinkedDirectories: false,
|
||||
onlyFiles: true,
|
||||
case: false,
|
||||
})
|
||||
|
||||
// the search order is such that we prefer files closer to the project root, then
|
||||
// we go by file size in ascending order, because people often have a main
|
||||
// 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
|
||||
const texFiles = await _sortFileList(unsortedTexFiles, directoryPath)
|
||||
let firstTexInRootFolder
|
||||
let doc = null
|
||||
|
||||
while (files.length > 0 && doc == null) {
|
||||
const file = files.shift()
|
||||
while (texFiles.length > 0 && doc == null) {
|
||||
const file = texFiles.shift()
|
||||
const content = await fs.promises.readFile(
|
||||
Path.join(directoryPath, file),
|
||||
'utf8'
|
||||
@@ -77,18 +73,52 @@ async function findRootDocFileFromDirectory(directoryPath) {
|
||||
if (DocumentHelper.contentHasDocumentclass(normalizedContent)) {
|
||||
doc = { path: file, content: normalizedContent }
|
||||
}
|
||||
|
||||
if (!firstFileInRootFolder && !file.includes('/')) {
|
||||
firstFileInRootFolder = { path: file, content: normalizedContent }
|
||||
if (!firstTexInRootFolder && !file.includes('/')) {
|
||||
firstTexInRootFolder = { path: file, content: normalizedContent }
|
||||
}
|
||||
}
|
||||
|
||||
// if no doc was found, use the first file in the root folder as the main doc
|
||||
if (!doc && firstFileInRootFolder) {
|
||||
doc = firstFileInRootFolder
|
||||
if (!doc && firstTexInRootFolder) {
|
||||
doc = firstTexInRootFolder
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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 LUMIERE_THEME_USER_CUTOFF_DATE = new Date(Date.UTC(2026, 5, 11, 12, 0, 0)) // 12pm GMT on June 11, 2026
|
||||
|
||||
function getOverallTheme(user) {
|
||||
if (user.ace.overallTheme != null) {
|
||||
@@ -10,7 +11,11 @@ function getOverallTheme(user) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return 'system'
|
||||
if (user.signUpDate < LUMIERE_THEME_USER_CUTOFF_DATE) {
|
||||
return 'system'
|
||||
}
|
||||
|
||||
return 'lumiere-'
|
||||
}
|
||||
|
||||
async function buildUserSettings(_req, _res, user) {
|
||||
@@ -41,4 +46,5 @@ async function buildUserSettings(_req, _res, user) {
|
||||
|
||||
export default {
|
||||
buildUserSettings,
|
||||
getOverallTheme,
|
||||
}
|
||||
|
||||
@@ -24,6 +24,15 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
|
||||
|
||||
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(
|
||||
defaultsDeep(
|
||||
{
|
||||
@@ -92,7 +101,7 @@ async function uploadFile(req, res, next) {
|
||||
await fsPromises.unlink(path).catch(unlinkErr => {
|
||||
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
|
||||
})
|
||||
return res.status(422).json({
|
||||
return sendUploadResponse(res, 422, {
|
||||
success: false,
|
||||
error: 'invalid_filename',
|
||||
})
|
||||
@@ -119,7 +128,7 @@ async function uploadFile(req, res, next) {
|
||||
await fsPromises.unlink(path).catch(unlinkErr => {
|
||||
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
|
||||
})
|
||||
throw error
|
||||
return sendUploadResponse(res, 500, { success: false })
|
||||
}
|
||||
|
||||
return FileSystemImportManager.addEntity(
|
||||
@@ -134,22 +143,22 @@ async function uploadFile(req, res, next) {
|
||||
timer.done()
|
||||
if (error != null) {
|
||||
if (error.name === 'InvalidNameError') {
|
||||
return res.status(422).json({
|
||||
return sendUploadResponse(res, 422, {
|
||||
success: false,
|
||||
error: 'invalid_filename',
|
||||
})
|
||||
} else if (error instanceof DuplicateNameError) {
|
||||
return res.status(422).json({
|
||||
return sendUploadResponse(res, 422, {
|
||||
success: false,
|
||||
error: 'duplicate_file_name',
|
||||
})
|
||||
} else if (error.message === 'project_has_too_many_files') {
|
||||
return res.status(422).json({
|
||||
return sendUploadResponse(res, 422, {
|
||||
success: false,
|
||||
error: 'project_has_too_many_files',
|
||||
})
|
||||
} else if (error.message === 'folder_not_found') {
|
||||
return res.status(422).json({
|
||||
return sendUploadResponse(res, 422, {
|
||||
success: false,
|
||||
error: 'folder_not_found',
|
||||
})
|
||||
@@ -164,10 +173,10 @@ async function uploadFile(req, res, next) {
|
||||
},
|
||||
'error uploading file'
|
||||
)
|
||||
return res.status(422).json({ success: false })
|
||||
return sendUploadResponse(res, 422, { success: false })
|
||||
}
|
||||
} else {
|
||||
return res.json({
|
||||
return sendUploadResponse(res, 200, {
|
||||
success: true,
|
||||
entity_id: entity?._id,
|
||||
entity_type: entity?.type,
|
||||
@@ -187,7 +196,7 @@ async function importDocument(req, res, next) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const { path } = req.file
|
||||
const conversionType = req.query.type
|
||||
if (!['docx', 'markdown'].includes(conversionType)) {
|
||||
if (!['docx', 'markdown', 'typst'].includes(conversionType)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: req.i18n.translate('invalid_import_type'),
|
||||
@@ -273,25 +282,28 @@ async function importDocument(req, res, next) {
|
||||
*/
|
||||
function multerMiddleware(req, res, next) {
|
||||
if (upload == null) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ success: false, error: req.i18n.translate('upload_failed') })
|
||||
return sendUploadResponse(res, 500, {
|
||||
success: false,
|
||||
error: req.i18n.translate('upload_failed'),
|
||||
})
|
||||
}
|
||||
return upload.single('qqfile')(
|
||||
req,
|
||||
res,
|
||||
/** @param {any} err */ function (err) {
|
||||
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res
|
||||
.status(422)
|
||||
.json({ success: false, error: req.i18n.translate('file_too_large') })
|
||||
return sendUploadResponse(res, 422, {
|
||||
success: false,
|
||||
error: req.i18n.translate('file_too_large'),
|
||||
})
|
||||
}
|
||||
if (err) return next(err)
|
||||
if (!req.file?.path) {
|
||||
logger.info({ req }, 'missing req.file.path on upload')
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: 'invalid_upload_request' })
|
||||
return sendUploadResponse(res, 400, {
|
||||
success: false,
|
||||
error: 'invalid_upload_request',
|
||||
})
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -14,10 +14,21 @@ import ProjectRootDocManager from '../Project/ProjectRootDocManager.mjs'
|
||||
import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.mjs'
|
||||
import ProjectDeleter from '../Project/ProjectDeleter.mjs'
|
||||
import { DeletedProjectReasons } from '../Project/DeletedProjectReasons.mjs'
|
||||
import ProjectOptionsHandler from '../Project/ProjectOptionsHandler.mjs'
|
||||
import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.mjs'
|
||||
import logger from '@overleaf/logger'
|
||||
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 {
|
||||
createProjectFromZipArchive: callbackify(createProjectFromZipArchive),
|
||||
createProjectFromZipArchiveWithName: callbackify(
|
||||
@@ -52,6 +63,11 @@ async function createProjectFromZipArchive(ownerId, defaultName, zipPath) {
|
||||
project._id,
|
||||
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) {
|
||||
// no need to wait for the cleanup here
|
||||
|
||||
@@ -6,6 +6,27 @@ import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
|
||||
import Settings from '@overleaf/settings'
|
||||
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 = {
|
||||
projectUpload: new RateLimiter('project-upload', {
|
||||
points: 20,
|
||||
@@ -62,6 +83,7 @@ export default {
|
||||
fileUploadRateLimit,
|
||||
AsyncLocalStorage.middleware,
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
startStreamingResponse,
|
||||
ProjectUploadController.multerMiddleware,
|
||||
ProjectUploadController.uploadFile
|
||||
)
|
||||
@@ -72,6 +94,7 @@ export default {
|
||||
AuthenticationController.requireLogin(),
|
||||
AsyncLocalStorage.middleware,
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
startStreamingResponse,
|
||||
ProjectUploadController.multerMiddleware,
|
||||
ProjectUploadController.uploadFile
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Settings from '@overleaf/settings'
|
||||
import Translations from '../../infrastructure/Translations.mjs'
|
||||
import UserHandler from './UserHandler.mjs'
|
||||
import UserDeleter from './UserDeleter.mjs'
|
||||
import UserGetter from './UserGetter.mjs'
|
||||
@@ -562,6 +564,30 @@ async function expireDeletedUsersAfterDuration(req, res, next) {
|
||||
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 {
|
||||
clearSessions: expressify(clearSessions),
|
||||
changePassword: expressify(changePassword),
|
||||
@@ -574,4 +600,5 @@ export default {
|
||||
expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration),
|
||||
ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware),
|
||||
ensureAffiliation,
|
||||
setLanguage: expressify(setLanguage),
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { fetchJson } from '@overleaf/fetch-utils'
|
||||
import contentDisposition from 'content-disposition'
|
||||
import Features from './Features.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 Modules from './Modules.mjs'
|
||||
import Errors from '../Features/Errors/Errors.js'
|
||||
@@ -15,6 +17,7 @@ import AdminAuthorizationHelper from '../Features/Helpers/AdminAuthorizationHelp
|
||||
import { addOptionalCleanupHandlerAfterDrainingConnections } from './GracefulShutdown.mjs'
|
||||
import { sanitizeSessionUserForFrontEnd } from './FrontEndUser.mjs'
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
import Translations from './Translations.mjs'
|
||||
|
||||
const {
|
||||
canRedirectToAdminDomain,
|
||||
@@ -269,6 +272,28 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
|
||||
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) {
|
||||
res.locals.getLoggedInUserId = () =>
|
||||
SessionManager.getLoggedInUserId(req.session)
|
||||
@@ -307,11 +332,15 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
|
||||
webRouter.use(function (req, res, next) {
|
||||
res.locals.overallThemes = [
|
||||
{
|
||||
name: 'Dark',
|
||||
name: 'Verso Lumière',
|
||||
val: 'lumiere-',
|
||||
},
|
||||
{
|
||||
name: 'Classic Dark',
|
||||
val: '',
|
||||
},
|
||||
{
|
||||
name: 'Light',
|
||||
name: 'Classic Light',
|
||||
val: 'light-',
|
||||
},
|
||||
{
|
||||
@@ -324,6 +353,7 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
|
||||
|
||||
webRouter.use(function (req, res, next) {
|
||||
res.locals.settings = Settings
|
||||
res.locals.availableLanguages = Translations.availableLanguageCodes
|
||||
next()
|
||||
})
|
||||
|
||||
|
||||
@@ -348,6 +348,7 @@ if (Settings.csp && Settings.csp.enabled) {
|
||||
|
||||
logger.debug('creating HTTP server'.yellow)
|
||||
const server = http.createServer(app)
|
||||
server.requestTimeout = 15 * 60 * 1000
|
||||
|
||||
// provide settings for separate web and api processes
|
||||
if (Settings.enabledServices.includes('api')) {
|
||||
|
||||
@@ -44,6 +44,8 @@ const locales = {
|
||||
'zh-CN': zhCN,
|
||||
}
|
||||
|
||||
const LANG_COOKIE_NAME = 'verso-lang'
|
||||
|
||||
const fallbackLanguageCode = Settings.i18n.defaultLng || 'en'
|
||||
const availableLanguageCodes = []
|
||||
const availableHosts = new Map()
|
||||
@@ -62,15 +64,20 @@ Object.values(Settings.i18n.subdomainLang || {}).forEach(function (spec) {
|
||||
subdomainConfigs.set(spec.lngCode, spec)
|
||||
}
|
||||
})
|
||||
if (!availableLanguageCodes.includes(fallbackLanguageCode)) {
|
||||
// always load the fallback locale
|
||||
availableLanguageCodes.push(fallbackLanguageCode)
|
||||
// Make all bundled locale files available regardless of subdomain config.
|
||||
// This allows the cookie-based language picker to offer every loaded locale
|
||||
// even when no subdomains are configured.
|
||||
for (const lngCode of Object.keys(locales)) {
|
||||
if (!availableLanguageCodes.includes(lngCode)) {
|
||||
availableLanguageCodes.push(lngCode)
|
||||
}
|
||||
}
|
||||
|
||||
const resources = Object.fromEntries(
|
||||
Object.entries(locales)
|
||||
.filter(([lngCode]) => availableLanguageCodes.includes(lngCode))
|
||||
.map(([lngCode, translations]) => [lngCode, { translation: translations }])
|
||||
Object.entries(locales).map(([lngCode, translations]) => [
|
||||
lngCode,
|
||||
{ translation: translations },
|
||||
])
|
||||
)
|
||||
|
||||
i18n
|
||||
@@ -107,8 +114,13 @@ i18n
|
||||
})
|
||||
|
||||
function setLangBasedOnDomainMiddleware(req, res, next) {
|
||||
// Determine language from subdomain
|
||||
const lang = availableHosts.get(req.headers.host) ?? fallbackLanguageCode
|
||||
// Priority: (1) user-set cookie, (2) subdomain, (3) env-var default
|
||||
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 = {
|
||||
language: lang,
|
||||
@@ -173,4 +185,6 @@ i18n.translate = i18n.t
|
||||
export default {
|
||||
setLangBasedOnDomainMiddleware,
|
||||
i18n,
|
||||
LANG_COOKIE_NAME,
|
||||
availableLanguageCodes,
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export const UserSchema = new Schema(
|
||||
},
|
||||
role: { type: String, default: '' },
|
||||
institution: { type: String, default: '' },
|
||||
languageCode: { type: String, default: null },
|
||||
hashedPassword: String,
|
||||
enrollment: {
|
||||
sso: [
|
||||
|
||||
@@ -284,6 +284,14 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
||||
'/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.post('/logout', UserController.logout)
|
||||
|
||||
@@ -611,6 +619,12 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
||||
CompileController.stopCompile
|
||||
)
|
||||
|
||||
webRouter.get(
|
||||
'/project/:Project_id/thumbnail',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
CompileController.getProjectThumbnail
|
||||
)
|
||||
|
||||
webRouter.get(
|
||||
'/project/:Project_id/output/cached/output.overleaf.json',
|
||||
AsyncLocalStorage.middleware,
|
||||
@@ -681,17 +695,17 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
||||
)
|
||||
webRouter.post(
|
||||
'/project/:Project_id/publish-presentation',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
AuthorizationMiddleware.ensureUserCanAdminProject,
|
||||
PublishedPresentationController.publish
|
||||
)
|
||||
webRouter.post(
|
||||
'/project/:Project_id/publish-presentation/regenerate',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
AuthorizationMiddleware.ensureUserCanAdminProject,
|
||||
PublishedPresentationController.regenerate
|
||||
)
|
||||
webRouter.delete(
|
||||
'/project/:Project_id/publish-presentation',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
AuthorizationMiddleware.ensureUserCanAdminProject,
|
||||
PublishedPresentationController.unpublish
|
||||
)
|
||||
// On-demand export of a RevealJS deck (download menu): html | pdf.
|
||||
@@ -822,6 +836,12 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
ProjectDownloadsController.downloadPreparedProjectExport
|
||||
)
|
||||
webRouter.post(
|
||||
'/project/:Project_id/doc/:Doc_id/convert/:type',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
ProjectDownloadsController.convertDocInProject
|
||||
)
|
||||
}
|
||||
|
||||
webRouter.get(
|
||||
|
||||
@@ -42,9 +42,9 @@ else if settings.overleaf
|
||||
meta(itemprop='image' content=buildImgPath('ol-brand/overleaf_og_logo.png'))
|
||||
meta(name='image' content=buildImgPath('ol-brand/overleaf_og_logo.png'))
|
||||
else
|
||||
//- the default image for Overleaf Community Edition/Server Pro
|
||||
meta(itemprop='image' content=buildBaseAssetPath() + 'apple-touch-icon.png')
|
||||
meta(name='image' content=buildBaseAssetPath() + 'apple-touch-icon.png')
|
||||
//- the default image for Verso Community Edition
|
||||
meta(itemprop='image' content=buildBaseAssetPath() + 'og-image.png')
|
||||
meta(name='image' content=buildBaseAssetPath() + 'og-image.png')
|
||||
|
||||
//- Keywords
|
||||
if metadata && metadata.keywords
|
||||
@@ -78,14 +78,17 @@ else if settings.overleaf
|
||||
content=buildImgPath('ol-brand/overleaf_og_logo.png')
|
||||
)
|
||||
else
|
||||
//- the default image for Overleaf Community Edition/Server Pro
|
||||
//- the default image for Verso Community Edition
|
||||
meta(
|
||||
name='twitter:image'
|
||||
content=buildBaseAssetPath() + 'apple-touch-icon.png'
|
||||
content=buildBaseAssetPath() + 'og-image.png'
|
||||
)
|
||||
|
||||
//- 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
|
||||
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')
|
||||
)
|
||||
else
|
||||
//- the default image for Overleaf Community Edition/Server Pro
|
||||
//- the default image for Verso Community Edition
|
||||
meta(
|
||||
property='og:image'
|
||||
content=buildBaseAssetPath() + 'apple-touch-icon.png'
|
||||
content=buildBaseAssetPath() + 'og-image.png'
|
||||
)
|
||||
|
||||
if metadata && metadata.openGraphType
|
||||
meta(property='og:type' metadata.openGraphType)
|
||||
meta(property='og:type' content=metadata.openGraphType)
|
||||
else
|
||||
meta(property='og:type' content='website')
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ html(
|
||||
'red-nav-bar-for-admins': !settings.isDevEnv && hasFeature('saas') && hasAdminAccess(),
|
||||
}
|
||||
data-theme='light'
|
||||
data-lumiere=isLumiere ? 'true' : 'false'
|
||||
)
|
||||
if settings.recaptcha && settings.recaptcha.siteKeyV3
|
||||
script(
|
||||
|
||||
@@ -49,6 +49,7 @@ block append meta
|
||||
showPoweredBy: !hasFeature('saas') && !settings.nav.hide_powered_by,
|
||||
subdomainLang: settings.i18n.subdomainLang,
|
||||
translatedLanguages: settings.translatedLanguages,
|
||||
availableLanguages: availableLanguages,
|
||||
leftItems: cloneAndTranslateText(settings.nav.left_footer),
|
||||
rightItems: settings.nav.right_footer,
|
||||
}
|
||||
|
||||
@@ -1,35 +1,43 @@
|
||||
include ../_mixins/material_symbol
|
||||
|
||||
li.dropdown.dropup.subdued.language-picker(dropdown)
|
||||
button#language-picker-toggle.btn.btn-link.btn-inline-link(
|
||||
dropdown-toggle
|
||||
data-ol-lang-selector-tooltip
|
||||
data-bs-toggle='dropdown'
|
||||
aria-haspopup='true'
|
||||
aria-expanded='false'
|
||||
aria-label='Select ' + translate('language')
|
||||
tooltip=translate('language')
|
||||
title=translate('language')
|
||||
)
|
||||
+material-symbol('translate')
|
||||
|
|
||||
span.language-picker-text #{settings.translatedLanguages[currentLngCode]}
|
||||
|
||||
ul.dropdown-menu.dropdown-menu-sm-width(
|
||||
role='menu'
|
||||
aria-labelledby='language-picker-toggle'
|
||||
)
|
||||
li.dropdown-header #{translate("language")}
|
||||
each subdomainDetails, subdomain in settings.i18n.subdomainLang
|
||||
if !subdomainDetails.hide
|
||||
- let isActive = subdomainDetails.lngCode === currentLngCode
|
||||
li.lng-option
|
||||
a.menu-indent(
|
||||
href=subdomainDetails.url + currentUrlWithQueryParams
|
||||
role='menuitem'
|
||||
class=['dropdown-item', {active: isActive}]
|
||||
aria-selected=isActive ? 'true' : 'false'
|
||||
)
|
||||
| #{settings.translatedLanguages[subdomainDetails.lngCode]}
|
||||
if subdomainDetails.lngCode === currentLngCode
|
||||
+material-symbol('check', 'dropdown-item-trailing-icon')
|
||||
li.language-picker
|
||||
details.language-picker-details
|
||||
summary#language-picker-toggle.btn-inline-link(
|
||||
aria-label=translate('select_a_language')
|
||||
translate='no'
|
||||
)
|
||||
span.material-symbols translate
|
||||
|
|
||||
span.language-picker-text= settings.translatedLanguages[currentLngCode] || currentLngCode
|
||||
ul.dropdown-menu.dropdown-menu-sm-width(
|
||||
role='menu'
|
||||
aria-labelledby='language-picker-toggle'
|
||||
translate='no'
|
||||
)
|
||||
each lngCode in availableLanguages
|
||||
if settings.translatedLanguages[lngCode]
|
||||
li(role='none')
|
||||
a.dropdown-item(
|
||||
role='menuitem'
|
||||
href='/set-language?lng=' + encodeURIComponent(lngCode) + '&return_to=/'
|
||||
data-lng=lngCode
|
||||
class=lngCode === currentLngCode ? 'active' : ''
|
||||
)= settings.translatedLanguages[lngCode]
|
||||
script.
|
||||
(function () {
|
||||
var details = document.querySelector('.language-picker-details')
|
||||
var menu = details && details.querySelector('.dropdown-menu')
|
||||
if (!details || !menu) return
|
||||
menu.querySelectorAll('a[data-lng]').forEach(function (a) {
|
||||
var lng = a.getAttribute('data-lng')
|
||||
a.href =
|
||||
'/set-language?lng=' +
|
||||
encodeURIComponent(lng) +
|
||||
'&return_to=' +
|
||||
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
|
||||
- var showLanguagePicker = Object.keys(settings.i18n.subdomainLang).length > 1
|
||||
- var showLanguagePicker = availableLanguages.length > 1
|
||||
- var hasCustomLeftNav = nav.left_footer && nav.left_footer.length > 0
|
||||
.site-footer-content.hidden-print
|
||||
.row
|
||||
@@ -8,22 +8,22 @@ footer.site-footer
|
||||
| © #{new Date().getFullYear()}
|
||||
|
|
||||
a(href='https://alocoq.fr' target='_blank' rel='noopener noreferrer') Aloïs Coquillard
|
||||
li
|
||||
li.footer-sep
|
||||
strong.text-muted |
|
||||
li
|
||||
| Built on
|
||||
| !{translate('built_on')}
|
||||
|
|
||||
a(href='https://github.com/overleaf/overleaf' target='_blank' rel='noopener noreferrer') Overleaf
|
||||
|
||||
if showLanguagePicker || hasCustomLeftNav
|
||||
li
|
||||
li.footer-sep
|
||||
strong.text-muted |
|
||||
|
||||
if showLanguagePicker
|
||||
include language-picker
|
||||
|
||||
if showLanguagePicker && hasCustomLeftNav
|
||||
li
|
||||
li.footer-sep
|
||||
strong.text-muted |
|
||||
|
||||
each item in nav.left_footer
|
||||
@@ -33,10 +33,10 @@ footer.site-footer
|
||||
else
|
||||
| !{item.text}
|
||||
|
||||
ul.site-footer-items.col-lg-3.text-end
|
||||
ul.site-footer-items.col-lg-3.text-lg-end
|
||||
li
|
||||
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 |
|
||||
li
|
||||
a(href='https://git.alocoq.fr/alois/verso' target='_blank' rel='noopener noreferrer') Source code
|
||||
|
||||
@@ -5,18 +5,18 @@ block vars
|
||||
- var suppressNavbar = true
|
||||
|
||||
block content
|
||||
main#main-content.content
|
||||
main#main-content.content.login-page
|
||||
.container
|
||||
.row
|
||||
.col-12
|
||||
.text-center.mb-4
|
||||
.lumiere-logo-center.mb-4
|
||||
img.verso-login-logo(
|
||||
src=buildImgPath('ol-brand/verso-logo.svg')
|
||||
alt='Verso'
|
||||
style='width:100%;max-width:480px;height:auto'
|
||||
style='width:100%;height:auto'
|
||||
)
|
||||
.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
|
||||
if login_support_title
|
||||
h1 !{login_support_title}
|
||||
|
||||
@@ -26,7 +26,7 @@ block content
|
||||
|
||||
main#main-content(data-ol-captcha-retry-trigger-area='')
|
||||
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
|
||||
form(
|
||||
name='passwordResetForm'
|
||||
|
||||
@@ -6,9 +6,10 @@ block vars
|
||||
block content
|
||||
main#main-content
|
||||
.auth-aux-container
|
||||
img.w-50.d-block(
|
||||
src=buildImgPath('ol-brand/overleaf.svg')
|
||||
alt=settings.appName
|
||||
img.d-block(
|
||||
src=buildImgPath('ol-brand/verso-logo.svg')
|
||||
alt='Verso'
|
||||
style='height:36px;width:auto;margin-bottom:1.5rem'
|
||||
)
|
||||
h1.h3.mb-3 #{translate("keep_your_account_safe")}
|
||||
div(data-ol-multi-submit)
|
||||
|
||||
@@ -8,7 +8,7 @@ block vars
|
||||
block content
|
||||
main#main-content
|
||||
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
|
||||
form(
|
||||
name='passwordResetForm'
|
||||
|
||||
@@ -77,8 +77,8 @@
|
||||
"add_another_address_line": "",
|
||||
"add_another_email": "",
|
||||
"add_another_token": "",
|
||||
"add_comma_separated_emails_help": "",
|
||||
"add_collaborators": "",
|
||||
"add_comma_separated_emails_help": "",
|
||||
"add_comment": "",
|
||||
"add_comment_error_message": "",
|
||||
"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": "",
|
||||
"aggregate_changed": "",
|
||||
"aggregate_to": "",
|
||||
"agpl_licence": "",
|
||||
"agree": "",
|
||||
"agree_with_the_terms": "",
|
||||
"ai_assist_in_overleaf_is_included_via_writefull_groups": "",
|
||||
@@ -228,6 +229,7 @@
|
||||
"breadcrumbs": "",
|
||||
"browser": "",
|
||||
"build_collection_of_most_used_references": "",
|
||||
"built_on": "",
|
||||
"bullet_list": "",
|
||||
"buy_licenses": "",
|
||||
"buy_more_licenses": "",
|
||||
@@ -385,6 +387,8 @@
|
||||
"continue_using_free_features": "",
|
||||
"continue_with_free_plan": "",
|
||||
"conversion_error_details": "",
|
||||
"convert_to_latex": "",
|
||||
"convert_to_typst": "",
|
||||
"cookie_banner": "",
|
||||
"cookie_banner_info": "",
|
||||
"copied": "",
|
||||
@@ -471,6 +475,8 @@
|
||||
"demonstrating_track_changes_feature": "",
|
||||
"department": "",
|
||||
"description": "",
|
||||
"card_size": "",
|
||||
"deselect_all": "",
|
||||
"details": "",
|
||||
"details_provided_by_google_explanation": "",
|
||||
"dictionary": "",
|
||||
@@ -665,7 +671,9 @@
|
||||
"explore_plans": "",
|
||||
"export_as_docx": "",
|
||||
"export_as_html": "",
|
||||
"export_as_latex": "",
|
||||
"export_as_markdown": "",
|
||||
"export_as_typst": "",
|
||||
"export_csv": "",
|
||||
"export_project_to_github": "",
|
||||
"failed": "",
|
||||
@@ -1010,6 +1018,7 @@
|
||||
"institution_templates": "",
|
||||
"integrations": "",
|
||||
"integrations_like_github": "",
|
||||
"interface_language": "",
|
||||
"interested_in_cheaper_personal_plan": "",
|
||||
"invalid_confirmation_code": "",
|
||||
"invalid_email": "",
|
||||
@@ -1093,6 +1102,7 @@
|
||||
"last_verified": "",
|
||||
"latam_discount_modal_info": "",
|
||||
"latam_discount_modal_title": "",
|
||||
"latex_export_feedback_message": "",
|
||||
"latex_places_figures_according_to_a_special_algorithm": "",
|
||||
"latex_places_tables_according_to_a_special_algorithm": "",
|
||||
"layout_options": "",
|
||||
@@ -1266,6 +1276,8 @@
|
||||
"n_more_updates_above_plural": "",
|
||||
"n_more_updates_below": "",
|
||||
"n_more_updates_below_plural": "",
|
||||
"n_projects_selected": "",
|
||||
"n_projects_selected_plural": "",
|
||||
"name": "",
|
||||
"name_usage_explanation": "",
|
||||
"navigation": "",
|
||||
@@ -1466,8 +1478,6 @@
|
||||
"please_ask_the_project_owner_to_upgrade_to_track_changes": "",
|
||||
"please_change_primary_to_remove": "",
|
||||
"please_compile_pdf_before_download": "",
|
||||
"python_packages": "",
|
||||
"python_packages_help": "",
|
||||
"please_confirm_primary_email_or_edit": "",
|
||||
"please_confirm_secondary_email_or_edit": "",
|
||||
"please_confirm_your_email_before_making_it_default": "",
|
||||
@@ -1502,14 +1512,14 @@
|
||||
"premium_plan_label": "",
|
||||
"preparing_for_export": "",
|
||||
"preparing_your_download": "",
|
||||
"present": "",
|
||||
"present_publishes_and_opens_in_new_tab": "",
|
||||
"presentation_export_can_take_a_moment": "",
|
||||
"presentation_export_failed": "",
|
||||
"presentation_link_members": "",
|
||||
"presentation_link_private": "",
|
||||
"presentation_link_public": "",
|
||||
"presentation_mode": "",
|
||||
"present": "",
|
||||
"present_publishes_and_opens_in_new_tab": "",
|
||||
"press_shift_space_for_suggestions": "",
|
||||
"press_space_to_open_the_ai_assistant": "",
|
||||
"preview": "",
|
||||
@@ -1582,6 +1592,8 @@
|
||||
"pull_github_changes_into_sharelatex": "",
|
||||
"push_sharelatex_changes_to_github": "",
|
||||
"push_to_github_pull_to_overleaf": "",
|
||||
"python_packages": "",
|
||||
"python_packages_help": "",
|
||||
"quoted_text": "",
|
||||
"raw_logs": "",
|
||||
"raw_logs_description": "",
|
||||
@@ -1906,6 +1918,7 @@
|
||||
"sort_by": "",
|
||||
"sort_by_x": "",
|
||||
"sort_projects": "",
|
||||
"source_code": "",
|
||||
"speak": "",
|
||||
"speech_input_not_available": "",
|
||||
"spellcheck": "",
|
||||
@@ -2214,6 +2227,7 @@
|
||||
"tooltip_hide_pdf": "",
|
||||
"tooltip_show_panel": "",
|
||||
"tooltip_show_pdf": "",
|
||||
"top_bottom_split_view": "",
|
||||
"total_due_in_x_days": "",
|
||||
"total_due_today": "",
|
||||
"total_per_month": "",
|
||||
@@ -2250,6 +2264,7 @@
|
||||
"turn_off_link_sharing": "",
|
||||
"turn_on": "",
|
||||
"turn_on_link_sharing": "",
|
||||
"typst_export_feedback_message": "",
|
||||
"unarchive": "",
|
||||
"uncategorized": "",
|
||||
"uncategorized_projects": "",
|
||||
|
||||
+18
@@ -173,6 +173,24 @@ export default function FileTreeUploadDoc() {
|
||||
// limit: maxConnections || 1,
|
||||
limit: 1,
|
||||
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
|
||||
.on('complete', result => {
|
||||
|
||||
+3
-1
@@ -30,7 +30,8 @@ function FileTreeItemInner({
|
||||
onClick?: () => void
|
||||
}) {
|
||||
const { fileTreeReadOnly } = useFileTreeData()
|
||||
const { setContextMenuCoords } = useFileTreeMainContext()
|
||||
const { setContextMenuCoords, setContextMenuEntityId } =
|
||||
useFileTreeMainContext()
|
||||
const { isRenaming } = useFileTreeActionable()
|
||||
const { selectedEntityIds } = useFileTreeSelectable()
|
||||
|
||||
@@ -73,6 +74,7 @@ function FileTreeItemInner({
|
||||
|
||||
ev.preventDefault()
|
||||
|
||||
setContextMenuEntityId(id)
|
||||
setContextMenuCoords({
|
||||
top: ev.pageY,
|
||||
left: ev.pageX,
|
||||
|
||||
+93
-5
@@ -2,12 +2,31 @@ import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as eventTracking from '../../../../infrastructure/event-tracking'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import type { ProjectCompiler } from '@ol-types/project-settings'
|
||||
|
||||
import {
|
||||
DropdownDivider,
|
||||
DropdownItem,
|
||||
} from '@/shared/components/dropdown/dropdown-menu'
|
||||
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() {
|
||||
const { t } = useTranslation()
|
||||
@@ -23,12 +42,62 @@ function FileTreeItemMenuItems() {
|
||||
startUploadingDocOrFile,
|
||||
downloadPath,
|
||||
selectedFileName,
|
||||
canSetRootDocId,
|
||||
setRootDocId,
|
||||
} = useFileTreeActionable()
|
||||
|
||||
const { project } = useProjectContext()
|
||||
const { project, projectId, updateProject } = useProjectContext()
|
||||
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(() => {
|
||||
// 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>
|
||||
</li>
|
||||
) : null}
|
||||
{canSetRootDocId ? (
|
||||
{canShowSetAsMain ? (
|
||||
<>
|
||||
<DropdownDivider />
|
||||
<li role="none">
|
||||
<DropdownItem onClick={setRootDocId}>
|
||||
<DropdownItem onClick={handleSetAsMain}>
|
||||
{t('set_as_main_document')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
</>
|
||||
) : 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 ? (
|
||||
<>
|
||||
<DropdownDivider />
|
||||
|
||||
+3
-1
@@ -5,7 +5,8 @@ import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function FileTreeItemMenu({ id, name }: { id: string; name: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
|
||||
const { contextMenuCoords, setContextMenuCoords, setContextMenuEntityId } =
|
||||
useFileTreeMainContext()
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const isMenuOpen = Boolean(contextMenuCoords)
|
||||
@@ -13,6 +14,7 @@ function FileTreeItemMenu({ id, name }: { id: string; name: string }) {
|
||||
function handleClick(event: React.MouseEvent) {
|
||||
event.stopPropagation()
|
||||
if (!contextMenuCoords && menuButtonRef.current) {
|
||||
setContextMenuEntityId(id)
|
||||
const target = menuButtonRef.current.getBoundingClientRect()
|
||||
setContextMenuCoords({
|
||||
top: target.top + target.height / 2,
|
||||
|
||||
@@ -9,6 +9,8 @@ const FileTreeMainContext = createContext<
|
||||
setStartedFreeTrial: (value: boolean) => void
|
||||
contextMenuCoords: ContextMenuCoords | null
|
||||
setContextMenuCoords: (value: ContextMenuCoords | null) => void
|
||||
contextMenuEntityId: string | null
|
||||
setContextMenuEntityId: (value: string | null) => void
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
@@ -39,6 +41,9 @@ export const FileTreeMainProvider: FC<
|
||||
}) => {
|
||||
const [contextMenuCoords, setContextMenuCoords] =
|
||||
useState<ContextMenuCoords | null>(null)
|
||||
const [contextMenuEntityId, setContextMenuEntityId] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
|
||||
return (
|
||||
<FileTreeMainContext.Provider
|
||||
@@ -48,6 +53,8 @@ export const FileTreeMainProvider: FC<
|
||||
setStartedFreeTrial,
|
||||
contextMenuCoords,
|
||||
setContextMenuCoords,
|
||||
contextMenuEntityId,
|
||||
setContextMenuEntityId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Panel, PanelGroup } from 'react-resizable-panels'
|
||||
import classNames from 'classnames'
|
||||
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 { RailLayout } from '../rail/rail'
|
||||
import { Toolbar } from '../toolbar/toolbar'
|
||||
@@ -8,7 +9,7 @@ import { HorizontalToggler } from '@/features/ide-react/components/resize/horizo
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePdfPane } from '@/features/ide-react/hooks/use-pdf-pane'
|
||||
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 { useRailContext } from '../../context/rail-context'
|
||||
import HistoryContainer from '@/features/ide-react/components/history-container'
|
||||
@@ -25,6 +26,19 @@ const mainEditorLayoutModalsModules: Array<{
|
||||
path: string
|
||||
}> = 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() {
|
||||
const [resizing, setResizing] = useState(false)
|
||||
const { resizing: railResizing } = useRailContext()
|
||||
@@ -38,8 +52,29 @@ export default function MainLayout() {
|
||||
} = usePdfPane()
|
||||
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 =
|
||||
view === 'editor' || view === 'file' || pdfLayout === 'sideBySide'
|
||||
view === 'editor' ||
|
||||
view === 'file' ||
|
||||
pdfLayout === 'sideBySide' ||
|
||||
pdfLayout === 'verticalSplit'
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -58,8 +93,20 @@ export default function MainLayout() {
|
||||
<Panel id="ide-redesign-editor-and-pdf-panel" order={2}>
|
||||
<HistoryContainer />
|
||||
<PanelGroup
|
||||
autoSaveId="ide-redesign-editor-and-pdf-panel-group"
|
||||
direction="horizontal"
|
||||
key={isVertical ? 'vertical' : '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({
|
||||
hidden: view === 'history',
|
||||
})}
|
||||
@@ -79,29 +126,38 @@ export default function MainLayout() {
|
||||
<EditorPanel />
|
||||
</div>
|
||||
</Panel>
|
||||
<HorizontalResizeHandle
|
||||
resizable={pdfLayout === 'sideBySide'}
|
||||
onDragging={setResizing}
|
||||
onDoubleClick={togglePdfPane}
|
||||
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')}
|
||||
{isVertical ? (
|
||||
<VerticalResizeHandle
|
||||
onDragging={setResizing}
|
||||
className={classNames({
|
||||
hidden: !editorIsOpen,
|
||||
})}
|
||||
/>
|
||||
{pdfLayout === 'sideBySide' && (
|
||||
<div className="synctex-controls">
|
||||
<DefaultSynctexControl />
|
||||
</div>
|
||||
)}
|
||||
</HorizontalResizeHandle>
|
||||
) : (
|
||||
<HorizontalResizeHandle
|
||||
resizable={pdfLayout === 'sideBySide'}
|
||||
onDragging={setResizing}
|
||||
onDoubleClick={togglePdfPane}
|
||||
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
|
||||
collapsible
|
||||
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 classNames from 'classnames'
|
||||
|
||||
type LayoutOption = 'sideBySide' | 'editorOnly' | 'pdfOnly' | 'detachedPdf'
|
||||
type LayoutOption =
|
||||
| 'sideBySide'
|
||||
| 'verticalSplit'
|
||||
| 'editorOnly'
|
||||
| 'pdfOnly'
|
||||
| 'detachedPdf'
|
||||
|
||||
const getActiveLayoutOption = ({
|
||||
pdfLayout,
|
||||
@@ -46,6 +51,10 @@ const getActiveLayoutOption = ({
|
||||
return 'sideBySide'
|
||||
}
|
||||
|
||||
if (pdfLayout === 'verticalSplit') {
|
||||
return 'verticalSplit'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -92,12 +101,14 @@ const shortcuts: Record<LayoutOption, string[] | null> = isMac
|
||||
editorOnly: ['⌃', '⌘', '←'],
|
||||
pdfOnly: ['⌃', '⌘', '→'],
|
||||
sideBySide: ['⌃', '⌘', '↓'],
|
||||
verticalSplit: null,
|
||||
detachedPdf: ['⌃', '⌘', '↑'],
|
||||
}
|
||||
: {
|
||||
editorOnly: null,
|
||||
pdfOnly: null,
|
||||
sideBySide: null,
|
||||
verticalSplit: null,
|
||||
detachedPdf: null,
|
||||
}
|
||||
|
||||
@@ -136,6 +147,18 @@ export default function ChangeLayoutOptions() {
|
||||
>
|
||||
{t('split_view')}
|
||||
</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
|
||||
onClick={() => handleChangeLayout('flat', 'editor')}
|
||||
active={activeLayoutOption === 'editorOnly'}
|
||||
|
||||
+6
-1
@@ -42,6 +42,7 @@ const ExportDocumentErrorToast = ({ data }: { data?: any }) => {
|
||||
}
|
||||
|
||||
const ExportDocumentSuccessToast = ({ data }: { data?: any }) => {
|
||||
const { t } = useTranslation()
|
||||
const type = data?.type
|
||||
if (type === 'docx') {
|
||||
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 {
|
||||
return (
|
||||
<Trans
|
||||
@@ -175,7 +180,7 @@ export const hidePreparingExportToast = (handle: string) => {
|
||||
}
|
||||
|
||||
export const showExportDocumentSuccess = (
|
||||
type: 'docx' | 'markdown' | 'html'
|
||||
type: 'docx' | 'markdown' | 'html' | 'typst' | 'latex'
|
||||
) => {
|
||||
window.dispatchEvent(
|
||||
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 { useRootDoc } from '@/shared/hooks/use-root-doc'
|
||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
|
||||
|
||||
type ExportProjectWithConversionProps = {
|
||||
featureFlag?: string
|
||||
conversionType: 'docx' | 'markdown' | 'html'
|
||||
conversionType: 'docx' | 'markdown' | 'html' | 'typst' | 'latex'
|
||||
label: string
|
||||
menuBarId: string
|
||||
}
|
||||
@@ -22,6 +23,11 @@ export const ExportProjectWithConversionButton: FC<
|
||||
const enablePandocConversions =
|
||||
getMeta('ol-ExposedSettings')?.enablePandocConversions
|
||||
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 { openDocs } = useEditorManagerContext()
|
||||
const downloadConversion = useConvertProject(
|
||||
@@ -31,7 +37,10 @@ export const ExportProjectWithConversionButton: FC<
|
||||
)
|
||||
|
||||
const showExportButton =
|
||||
splitTestEnabledIfNeeded && enablePandocConversions && !anonymous
|
||||
splitTestEnabledIfNeeded &&
|
||||
enablePandocConversions &&
|
||||
!anonymous &&
|
||||
isProjectTypeMatch
|
||||
|
||||
useCommandProvider(
|
||||
() =>
|
||||
|
||||
@@ -97,6 +97,8 @@ export const ToolbarMenuBar = () => {
|
||||
'export-as-docx',
|
||||
'export-as-markdown',
|
||||
'export-as-html',
|
||||
'export-as-typst',
|
||||
'export-as-latex',
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -243,7 +245,7 @@ export const ToolbarMenuBar = () => {
|
||||
>
|
||||
<ChangeLayoutOptions />
|
||||
<DropdownDivider />
|
||||
<DropdownHeader>Editor settings</DropdownHeader>
|
||||
<DropdownHeader>{t('editor_settings')}</DropdownHeader>
|
||||
<MenuBarOption
|
||||
eventKey="show_breadcrumbs"
|
||||
title={t('show_breadcrumbs')}
|
||||
|
||||
@@ -95,6 +95,16 @@ export const ToolbarProjectTitle = () => {
|
||||
label={t('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 />
|
||||
<DuplicateProject />
|
||||
<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
|
||||
|
||||
export default function useConvertProject(
|
||||
type: 'docx' | 'markdown' | 'html',
|
||||
type: 'docx' | 'markdown' | 'html' | 'typst' | 'latex',
|
||||
openDocs: OpenDocuments,
|
||||
getRootDocInfo: () => RootDocInfo
|
||||
) {
|
||||
@@ -45,7 +45,7 @@ export default function useConvertProject(
|
||||
if (downloadUrl) {
|
||||
const url = new URL(downloadUrl, window.location.origin)
|
||||
location.assign(url.toString())
|
||||
showExportDocumentSuccess(type)
|
||||
showExportDocumentSuccess(type as 'docx' | 'markdown' | 'html' | 'typst' | 'latex')
|
||||
} else {
|
||||
showExportDocumentError()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ export const usePdfPane = () => {
|
||||
useLayoutContext()
|
||||
|
||||
const pdfPanelRef = useRef<ImperativePanelHandle>(null)
|
||||
const pdfIsOpen = pdfLayout === 'sideBySide' || view === 'pdf'
|
||||
const pdfIsOpen =
|
||||
pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit' || view === 'pdf'
|
||||
|
||||
useCollapsiblePanel(pdfIsOpen, pdfPanelRef)
|
||||
|
||||
@@ -46,7 +47,7 @@ export const usePdfPane = () => {
|
||||
|
||||
// triggered when the PDF pane becomes closed (either by dragging or toggling)
|
||||
const handlePdfPaneCollapse = useCallback(() => {
|
||||
if (pdfLayout === 'sideBySide') {
|
||||
if (pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit') {
|
||||
changeLayout('flat', 'editor')
|
||||
}
|
||||
}, [changeLayout, pdfLayout])
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ function SwitchToEditorButton() {
|
||||
return null
|
||||
}
|
||||
|
||||
if (pdfLayout === 'sideBySide') {
|
||||
if (pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit') {
|
||||
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 { Project } from '../../../../../../types/project/dashboard/api'
|
||||
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 MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLSpinner from '@/shared/components/ol/ol-spinner'
|
||||
@@ -77,32 +78,81 @@ function ActionsDropdown({ project }: ActionDropdownProps) {
|
||||
</li>
|
||||
)}
|
||||
</DownloadProjectButton>
|
||||
<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>
|
||||
{project.compiler === 'quarto' ? (
|
||||
<DownloadPresentationButton project={project}>
|
||||
{(startExport, exporting) => (
|
||||
<>
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => startExport('html')}
|
||||
disabled={exporting !== null}
|
||||
leadingIcon={
|
||||
exporting === 'html' ? (
|
||||
<OLSpinner
|
||||
size="sm"
|
||||
className="dropdown-item-leading-icon spinner"
|
||||
/>
|
||||
) : (
|
||||
'download'
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('download_as_standalone_html')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
<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}>
|
||||
{(text, handleOpenModal) => (
|
||||
<li role="none">
|
||||
|
||||
@@ -67,7 +67,7 @@ function NewProjectButton({
|
||||
const markdownImportEnabled =
|
||||
useFeatureFlag('import-markdown') &&
|
||||
getMeta('ol-ExposedSettings').enablePandocConversions
|
||||
const { selectedTagId, tags } = useProjectListContext()
|
||||
const { selectedTagId, tags } = useProjectListContext()
|
||||
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
|
||||
const initialTags =
|
||||
isLibraryEnabled && selectedTagId
|
||||
|
||||
+7
-1
@@ -19,7 +19,7 @@ function ImportDocumentModal({
|
||||
onHide,
|
||||
openProject,
|
||||
}: {
|
||||
type: 'docx' | 'markdown'
|
||||
type: 'docx' | 'markdown' | 'typst'
|
||||
onHide: () => void
|
||||
openProject: (id: string, convertedFrom?: string) => void
|
||||
}) {
|
||||
@@ -39,6 +39,12 @@ function ImportDocumentModal({
|
||||
browseLabel: 'Select .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]
|
||||
)
|
||||
|
||||
+11
@@ -21,6 +21,7 @@ export type NewProjectButtonModalVariant =
|
||||
| 'import_from_github'
|
||||
| 'import_docx'
|
||||
| 'import_markdown'
|
||||
| 'import_typst'
|
||||
|
||||
type NewProjectButtonModalProps = {
|
||||
modal: Nullable<NewProjectButtonModalVariant>
|
||||
@@ -128,6 +129,16 @@ function NewProjectButtonModal({
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
case 'import_typst':
|
||||
return (
|
||||
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
|
||||
<ImportDocumentModal
|
||||
type="typst"
|
||||
onHide={onHide}
|
||||
openProject={openProject}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
case 'import_from_github':
|
||||
return <ImportProjectFromGithubModalWrapper onHide={onHide} />
|
||||
default:
|
||||
|
||||
+5
-4
@@ -128,20 +128,21 @@ function BannerContent({
|
||||
}: {
|
||||
variant: GroupsAndEnterpriseBannerVariant
|
||||
}) {
|
||||
const { appName } = getMeta('ol-ExposedSettings')
|
||||
switch (variant) {
|
||||
case 'on-premise':
|
||||
return (
|
||||
<span>
|
||||
Overleaf On-Premises: Does your company want to keep its data within
|
||||
its firewall? Overleaf offers Server Pro, an on-premises solution for
|
||||
{appName} On-Premises: Does your company want to keep its data within
|
||||
its firewall? {appName} offers Server Pro, an on-premises solution for
|
||||
companies. Get in touch to learn more.
|
||||
</span>
|
||||
)
|
||||
case 'FOMO':
|
||||
return (
|
||||
<span>
|
||||
Why do Fortune 500 companies and top research institutions trust
|
||||
Overleaf to streamline their collaboration? Get in touch to learn
|
||||
Why do Fortune 500 companies and top research institutions trust{' '}
|
||||
{appName} to streamline their collaboration? Get in touch to learn
|
||||
more.
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -44,37 +44,19 @@ export function ProjectListDsNav() {
|
||||
|
||||
const tableTopArea = (
|
||||
<div className="pt-2 pb-3 d-md-none d-flex gap-2">
|
||||
{isLibraryEnabled ? (
|
||||
<>
|
||||
<SearchForm
|
||||
inputValue={searchText}
|
||||
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"
|
||||
/>
|
||||
</>
|
||||
{showNewProjectButton && (
|
||||
<NewProjectButton
|
||||
id="new-project-button-projects-table"
|
||||
showAddAffiliationWidget
|
||||
/>
|
||||
)}
|
||||
<SearchForm
|
||||
inputValue={searchText}
|
||||
setInputValue={setSearchText}
|
||||
filter={filter}
|
||||
selectedTag={selectedTag}
|
||||
className="overflow-hidden flex-grow-1"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -107,9 +89,6 @@ export function ProjectListDsNav() {
|
||||
<ProjectTools />
|
||||
)}
|
||||
</div>
|
||||
<div className="d-md-none">
|
||||
<CurrentPlanWidget />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="project-ds-nav-project-list">
|
||||
@@ -145,8 +124,8 @@ export function ProjectListDsNav() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
{tableTopArea}
|
||||
<TableContainer bordered>
|
||||
{tableTopArea}
|
||||
<ProjectListTable />
|
||||
</TableContainer>
|
||||
</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 WelcomePageContent from '@/features/project-list/components/welcome-page-content'
|
||||
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 CookieBanner from '@/shared/components/cookie-banner'
|
||||
import useThemedPage from '@/shared/hooks/use-themed-page'
|
||||
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
|
||||
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
|
||||
import { TutorialProvider } from '@/shared/context/tutorial-context'
|
||||
|
||||
function ProjectListRoot() {
|
||||
@@ -80,6 +82,9 @@ function ProjectListPageContent() {
|
||||
useThemedPage()
|
||||
const { totalProjectsCount, isLoading, loadProgress } =
|
||||
useProjectListContext()
|
||||
const {
|
||||
userSettings: { overallTheme },
|
||||
} = useUserSettingsContext()
|
||||
|
||||
useEffect(() => {
|
||||
eventTracking.sendMB('loads_v2_dash', { page: 'projects' })
|
||||
@@ -95,6 +100,14 @@ function ProjectListPageContent() {
|
||||
return loadingComponent
|
||||
}
|
||||
|
||||
if (overallTheme === 'lumiere-') {
|
||||
return (
|
||||
<DsNavStyleProvider>
|
||||
<ProjectListLumiere />
|
||||
</DsNavStyleProvider>
|
||||
)
|
||||
}
|
||||
|
||||
if (totalProjectsCount === 0) {
|
||||
return (
|
||||
<>
|
||||
@@ -105,6 +118,7 @@ function ProjectListPageContent() {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DsNavStyleProvider>
|
||||
<ProjectListDsNav />
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ function SidebarDsNav() {
|
||||
scrolledUp && 'show-shadow'
|
||||
)}
|
||||
>
|
||||
<SidebarLowerSection showThemeToggle>
|
||||
<SidebarLowerSection showThemeToggle showAccountIcons={false}>
|
||||
<div className="project-list-sidebar-survey-wrapper">
|
||||
<SurveyWidgetDsNav />
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
const getIcon = (theme: OverallThemeMeta) => {
|
||||
switch (theme.val) {
|
||||
case 'lumiere-':
|
||||
return 'auto_awesome'
|
||||
case 'light-':
|
||||
return 'light_mode'
|
||||
case 'system':
|
||||
|
||||
+4
@@ -98,6 +98,10 @@ function CompileAndDownloadProjectPDFButton({
|
||||
const outputFile = data.outputFiles
|
||||
.filter((file: { path: string }) => file.path === 'output.pdf')
|
||||
.pop()
|
||||
if (!outputFile) {
|
||||
setShowErrorModal(true)
|
||||
return
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
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 { DeleteProjectButtonTooltip } from './action-buttons/delete-project-button'
|
||||
import { CompileAndDownloadProjectPDFButtonTooltip } from './action-buttons/compile-and-download-project-pdf-button'
|
||||
import { DownloadPresentationButtonTooltip } from './action-buttons/download-presentation-button'
|
||||
|
||||
type ActionsCellProps = {
|
||||
project: Project
|
||||
}
|
||||
|
||||
const isQuartoSlides = (project: Project) =>
|
||||
project.compiler === 'quarto'
|
||||
|
||||
export default function ActionsCell({ project }: ActionsCellProps) {
|
||||
return (
|
||||
<>
|
||||
<CopyProjectButtonTooltip project={project} />
|
||||
<DownloadProjectButtonTooltip project={project} />
|
||||
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
|
||||
{isQuartoSlides(project) ? (
|
||||
<DownloadPresentationButtonTooltip project={project} />
|
||||
) : (
|
||||
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
|
||||
)}
|
||||
<ArchiveProjectButtonTooltip project={project} />
|
||||
<TrashProjectButtonTooltip project={project} />
|
||||
<UnarchiveProjectButtonTooltip project={project} />
|
||||
|
||||
+2
-4
@@ -28,9 +28,10 @@ function ProjectListTableRow({ project, selected }: ProjectListTableRowProps) {
|
||||
</a>{' '}
|
||||
<InlineTags className="d-none d-md-inline" projectId={project.id} />
|
||||
</td>
|
||||
<td className="dash-cell-date-owner pb-0 d-md-none">
|
||||
<td className="dash-cell-meta d-md-none">
|
||||
<LastUpdatedCell project={project} />
|
||||
{ownerName ? <ProjectListOwnerName ownerName={ownerName} /> : null}
|
||||
<InlineTags projectId={project.id} className="ms-1" />
|
||||
</td>
|
||||
<td className="dash-cell-format d-none d-md-table-cell">
|
||||
<FormatCell project={project} />
|
||||
@@ -41,9 +42,6 @@ function ProjectListTableRow({ project, selected }: ProjectListTableRowProps) {
|
||||
<td className="dash-cell-date d-none d-md-table-cell">
|
||||
<LastUpdatedCell project={project} />
|
||||
</td>
|
||||
<td className="dash-cell-tag pt-0 d-md-none">
|
||||
<InlineTags projectId={project.id} />
|
||||
</td>
|
||||
<td className="dash-cell-actions">
|
||||
<div className="d-none d-lg-block">
|
||||
<ActionsCell project={project} />
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function WelcomeMessage() {
|
||||
{wikiEnabled && (
|
||||
<WelcomeMessageLink
|
||||
imgSrc={learnLatexImage}
|
||||
title="Learn LaTeX with a tutorial"
|
||||
title={t('learn_latex_with_a_tutorial')}
|
||||
href="/learn/latex/Learn_LaTeX_in_30_minutes"
|
||||
target="_blank"
|
||||
/>
|
||||
@@ -38,7 +38,7 @@ export default function WelcomeMessage() {
|
||||
{templatesEnabled && (
|
||||
<WelcomeMessageLink
|
||||
imgSrc={browseTemplatesImage}
|
||||
title="Browse templates"
|
||||
title={t('browse_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 { useSetCompilationSettingWithEvent } from '@/features/editor-left-menu/hooks/use-set-compilation-setting'
|
||||
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
|
||||
// dispatches the real engine from this extension (.qmd → Quarto, .typ → Typst,
|
||||
@@ -34,7 +35,7 @@ export default function CompilerSetting() {
|
||||
const [compilerOptions] = useState(() => getCompilerOptions())
|
||||
const { t } = useTranslation()
|
||||
const { write } = usePermissionsContext()
|
||||
const { docs } = useFileTreeData()
|
||||
const { docs, fileTreeData } = useFileTreeData()
|
||||
const changeCompiler = useSetCompilationSettingWithEvent(
|
||||
'compiler',
|
||||
setCompiler
|
||||
@@ -43,14 +44,21 @@ export default function CompilerSetting() {
|
||||
// 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.
|
||||
const options = useMemo(() => {
|
||||
const rootDoc = rootDocId
|
||||
? docs?.find(doc => doc.doc.id === rootDocId)
|
||||
: undefined
|
||||
const extension = rootDoc?.doc.name.split('.').pop()?.toLowerCase()
|
||||
let rootDocName: string | undefined
|
||||
if (rootDocId) {
|
||||
const fromDocs = docs?.find(doc => doc.doc.id === rootDocId)
|
||||
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
|
||||
|
||||
if (!allowed) {
|
||||
// Unknown / no root file: don't restrict anything.
|
||||
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 SpellCheckSetting from '@/features/settings/components/editor-settings/spell-check-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 BreadcrumbsSetting from '@/features/settings/components/editor-settings/breadcrumbs-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',
|
||||
component: <DictionarySetting />,
|
||||
},
|
||||
{
|
||||
key: 'interfaceLanguage',
|
||||
component: <InterfaceLanguageSetting />,
|
||||
},
|
||||
],
|
||||
},
|
||||
...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
|
||||
}
|
||||
|
||||
if (pdfLayout === 'sideBySide') {
|
||||
if (pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit') {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
+60
-1
@@ -12,7 +12,10 @@ import { MathDropdown } from './math-dropdown'
|
||||
import { InsertListDropdown } from './insert-list-dropdown'
|
||||
import { TableDropdown } from './table-dropdown'
|
||||
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 { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
|
||||
@@ -41,6 +44,7 @@ export const ToolbarItems: FC<{
|
||||
const { features } = useProjectContext()
|
||||
const permissions = usePermissionsContext()
|
||||
const isActive = withinFormattingCommand(state)
|
||||
const isTypstActive = withinTypstFormatting(state)
|
||||
|
||||
const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
} from '@codemirror/search'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import { toggleRanges, wrapRanges } from '../../commands/ranges'
|
||||
import {
|
||||
toggleTypstMarkup,
|
||||
wrapTypstLink,
|
||||
} from '../../commands/typst-ranges'
|
||||
import {
|
||||
ancestorListType,
|
||||
toggleListForRanges,
|
||||
@@ -25,6 +29,11 @@ import { sendSearchEvent } from '@/features/event-tracking/search-events'
|
||||
|
||||
export const toggleBold = toggleRanges('\\textbf')
|
||||
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: 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',
|
||||
},
|
||||
},
|
||||
|
||||
// ── 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()
|
||||
|
||||
@@ -20,6 +20,8 @@ import { listItemMarker } from './list-item-marker'
|
||||
import { pasteHtml } from './paste-html'
|
||||
import { commandTooltip } from '../command-tooltip'
|
||||
import { tableGeneratorTheme } from './table-generator'
|
||||
import { typstDecorations } from './typst-decorations'
|
||||
import { quartoDecorations } from './quarto-decorations'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { PreviewPath } from '../../../../../../types/preview-path'
|
||||
|
||||
@@ -183,6 +185,8 @@ const extension = (options: Options) => [
|
||||
atomicDecorations(options),
|
||||
visualEditorExtensions.map(extension => extension(options)),
|
||||
markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets
|
||||
typstDecorations,
|
||||
quartoDecorations,
|
||||
visualKeymap,
|
||||
commandTooltip,
|
||||
scrollJumpAdjuster,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { styleTags, tags as t } from '@lezer/highlight'
|
||||
import { parser } from '../../lezer-typst/typst.mjs'
|
||||
import { typstCompletions } from './complete'
|
||||
import { typstDocumentOutline } from './document-outline'
|
||||
import { shortcuts } from './shortcuts'
|
||||
|
||||
// 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.
|
||||
@@ -111,5 +112,6 @@ export const typst = () => {
|
||||
TypstLanguage.data.of({ autocomplete: typstCompletions }),
|
||||
typstDocumentOutline,
|
||||
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,
|
||||
},
|
||||
])
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,20 @@ import {
|
||||
matchingAncestor,
|
||||
} from '@/features/source-editor/utils/tree-operations/ancestors'
|
||||
|
||||
export type TypstFormattingNode = 'Strong' | 'Emphasis'
|
||||
|
||||
export const withinTypstFormatting = (state: EditorState) => {
|
||||
const tree = syntaxTree(state)
|
||||
|
||||
return (nodeTypeName: TypstFormattingNode): boolean => {
|
||||
const isFormatted = (range: SelectionRange): boolean => {
|
||||
const node = tree.resolveInner(range.from, -1)
|
||||
return Boolean(matchingAncestor(node, n => n.type.name === nodeTypeName))
|
||||
}
|
||||
return state.selection.ranges.every(isFormatted)
|
||||
}
|
||||
}
|
||||
|
||||
export type FormattingCommand = '\\textbf' | '\\textit'
|
||||
export type FormattingNodeType = string | number
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ async function uploadOne(
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': getMeta('ol-csrfToken'),
|
||||
},
|
||||
signal: AbortSignal.timeout(15 * 60 * 1000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
import OLRow from '@/shared/components/ol/ol-row'
|
||||
import LanguagePicker from '@/shared/components/language-picker'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function FooterItemLi({
|
||||
text,
|
||||
@@ -34,16 +35,19 @@ function FooterItemLi({
|
||||
|
||||
function Separator() {
|
||||
return (
|
||||
<li role="separator" className="text-muted">
|
||||
<li role="separator" className="text-muted footer-sep">
|
||||
<strong>|</strong>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function ThinFooter({ subdomainLang, leftItems, rightItems }: FooterMetadata) {
|
||||
const showLanguagePicker = Boolean(
|
||||
subdomainLang && Object.keys(subdomainLang).length > 1
|
||||
)
|
||||
function ThinFooter({
|
||||
availableLanguages,
|
||||
leftItems,
|
||||
rightItems,
|
||||
}: FooterMetadata) {
|
||||
const { t } = useTranslation()
|
||||
const showLanguagePicker = (availableLanguages?.length ?? 0) > 1
|
||||
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
@@ -62,7 +66,7 @@ function ThinFooter({ subdomainLang, leftItems, rightItems }: FooterMetadata) {
|
||||
</li>
|
||||
<Separator />
|
||||
<li>
|
||||
Built on{' '}
|
||||
{t('built_on')}{' '}
|
||||
<a
|
||||
href="https://github.com/overleaf/overleaf"
|
||||
target="_blank"
|
||||
@@ -83,14 +87,14 @@ function ThinFooter({ subdomainLang, leftItems, rightItems }: FooterMetadata) {
|
||||
<FooterItemLi key={item.text} {...item} />
|
||||
))}
|
||||
</ul>
|
||||
<ul className="site-footer-items col-lg-3 text-end">
|
||||
<ul className="site-footer-items col-lg-3 text-lg-end">
|
||||
<li>
|
||||
<a
|
||||
href="https://git.alocoq.fr/alois/verso/src/branch/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
AGPL licence
|
||||
{t('agpl_licence')}
|
||||
</a>
|
||||
</li>
|
||||
<Separator />
|
||||
@@ -100,7 +104,7 @@ function ThinFooter({ subdomainLang, leftItems, rightItems }: FooterMetadata) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Source code
|
||||
{t('source_code')}
|
||||
</a>
|
||||
</li>
|
||||
{rightItems?.map(item => (
|
||||
|
||||
@@ -14,23 +14,23 @@ function LanguagePicker({ showHeader } = { showHeader: false }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const currentLangCode = getMeta('ol-i18n').currentLangCode
|
||||
const translatedLanguages = getMeta('ol-footer').translatedLanguages
|
||||
const subdomainLang = getMeta('ol-footer').subdomainLang
|
||||
const currentUrlWithQueryParams = window.location.pathname
|
||||
const footerMeta = getMeta('ol-footer')
|
||||
const translatedLanguages = footerMeta?.translatedLanguages
|
||||
const availableLanguages: string[] = footerMeta?.availableLanguages ?? []
|
||||
const currentUrl = window.location.pathname
|
||||
|
||||
return (
|
||||
<Dropdown drop="up">
|
||||
<DropdownToggle
|
||||
id="language-picker-toggle"
|
||||
aria-label={t('select_a_language')}
|
||||
data-bs-toggle="dropdown"
|
||||
className="btn-inline-link"
|
||||
variant="link"
|
||||
>
|
||||
<MaterialIcon type="translate" />
|
||||
|
||||
<span className="language-picker-text">
|
||||
{translatedLanguages?.[currentLangCode]}
|
||||
{translatedLanguages?.[currentLangCode] ?? currentLangCode}
|
||||
</span>
|
||||
</DropdownToggle>
|
||||
|
||||
@@ -39,24 +39,19 @@ function LanguagePicker({ showHeader } = { showHeader: false }) {
|
||||
aria-labelledby="language-picker-toggle"
|
||||
>
|
||||
{showHeader ? <DropdownHeader>{t('language')}</DropdownHeader> : null}
|
||||
{subdomainLang &&
|
||||
Object.entries(subdomainLang).map(([subdomain, subdomainDetails]) => {
|
||||
if (
|
||||
!subdomainDetails ||
|
||||
!subdomainDetails.lngCode ||
|
||||
subdomainDetails.hide
|
||||
)
|
||||
return null
|
||||
const isActive = subdomainDetails.lngCode === currentLangCode
|
||||
{availableLanguages
|
||||
.filter(lng => translatedLanguages?.[lng])
|
||||
.map(lng => {
|
||||
const isActive = lng === currentLangCode
|
||||
return (
|
||||
<li role="none" key={subdomain} translate="no">
|
||||
<li role="none" key={lng} translate="no">
|
||||
<DropdownItem
|
||||
href={`${subdomainDetails.url}${currentUrlWithQueryParams}`}
|
||||
href={`/set-language?lng=${encodeURIComponent(lng)}&return_to=${encodeURIComponent(currentUrl)}`}
|
||||
active={isActive}
|
||||
aria-current={isActive ? 'true' : false}
|
||||
trailingIcon={isActive ? 'check' : null}
|
||||
>
|
||||
{translatedLanguages?.[subdomainDetails.lngCode]}
|
||||
{translatedLanguages![lng]}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { NavbarSessionUser } from '@/shared/components/types/navbar'
|
||||
import NavLinkItem from '@/shared/components/navbar/nav-link-item'
|
||||
import { AccountMenuItems } from './account-menu-items'
|
||||
import { useSendProjectListMB } from '@/features/project-list/components/project-list-events'
|
||||
import { User } from '@phosphor-icons/react'
|
||||
|
||||
export default function LoggedInItems({
|
||||
sessionUser,
|
||||
@@ -20,7 +21,7 @@ export default function LoggedInItems({
|
||||
{t('projects')}
|
||||
</NavLinkItem>
|
||||
<NavDropdownMenu
|
||||
title={t('Account')}
|
||||
title={<><User size={20} className="me-1" />{t('Account')}</>}
|
||||
className="nav-item-account"
|
||||
onToggle={nextShow => {
|
||||
if (nextShow) {
|
||||
@@ -34,6 +35,7 @@ export default function LoggedInItems({
|
||||
<AccountMenuItems
|
||||
sessionUser={sessionUser}
|
||||
showSubscriptionLink={showSubscriptionLink}
|
||||
showThemeToggle
|
||||
/>
|
||||
</NavDropdownMenu>
|
||||
</>
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function NavDropdownMenu({
|
||||
children,
|
||||
onToggle,
|
||||
}: {
|
||||
title: string
|
||||
title: ReactNode
|
||||
className?: string
|
||||
children: ReactNode
|
||||
onToggle?: (nextShow: boolean) => void
|
||||
|
||||
@@ -16,11 +16,13 @@ import versoLogoDark from '@/shared/svgs/verso-logo-dark.svg'
|
||||
|
||||
export function SidebarLowerSection({
|
||||
showThemeToggle = false,
|
||||
showAccountIcons = true,
|
||||
accountRef,
|
||||
onAccountOpen,
|
||||
children,
|
||||
}: {
|
||||
showThemeToggle?: boolean
|
||||
showAccountIcons?: boolean
|
||||
accountRef?: Ref<HTMLDivElement>
|
||||
onAccountOpen?: () => void
|
||||
children?: React.ReactNode
|
||||
@@ -42,7 +44,7 @@ export function SidebarLowerSection({
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<nav
|
||||
{showAccountIcons && <nav
|
||||
className="d-flex flex-row gap-3 mb-2"
|
||||
aria-label={t('account_help')}
|
||||
>
|
||||
@@ -132,7 +134,7 @@ export function SidebarLowerSection({
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
)}
|
||||
</nav>
|
||||
</nav>}
|
||||
<a
|
||||
href="/"
|
||||
className="ds-nav-verso-logo"
|
||||
|
||||
@@ -13,6 +13,7 @@ export type FooterMetadata = {
|
||||
translatedLanguages: { [key: string]: string }
|
||||
showPoweredBy?: boolean
|
||||
subdomainLang?: SubdomainLang
|
||||
availableLanguages?: string[]
|
||||
leftItems?: FooterItem[]
|
||||
rightItems?: FooterItem[]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import usePersistedState from '@/shared/hooks/use-persisted-state'
|
||||
import { repositionAllTooltips } from '@/features/source-editor/extensions/tooltips-reposition'
|
||||
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
|
||||
|
||||
export type IdeLayout = 'sideBySide' | 'flat'
|
||||
export type IdeLayout = 'sideBySide' | 'flat' | 'verticalSplit'
|
||||
export type IdeView = 'editor' | 'file' | 'pdf' | 'history'
|
||||
|
||||
export type LayoutContextOwnStates = {
|
||||
@@ -77,10 +77,39 @@ export const LayoutContext = createContext<LayoutContextValue | undefined>(
|
||||
function setLayoutInLocalStorage(pdfLayout: IdeLayout) {
|
||||
localStorage.setItem(
|
||||
'pdf.layout',
|
||||
pdfLayout === 'sideBySide' ? 'split' : 'flat'
|
||||
pdfLayout === 'sideBySide'
|
||||
? 'split'
|
||||
: pdfLayout === 'verticalSplit'
|
||||
? 'vertical'
|
||||
: 'flat'
|
||||
)
|
||||
}
|
||||
|
||||
const MOBILE_MQ = '(max-width: 767px)'
|
||||
// Secondary touch check: catches browsers that spoof viewport width (e.g. Tor
|
||||
// Browser's fingerprinting resistance reports ~980px on an Android phone).
|
||||
// `pointer: coarse` reflects real hardware and is not spoofed.
|
||||
const TOUCH_MQ = '(pointer: coarse) and (max-width: 1024px)'
|
||||
|
||||
function isMobileDevice(): boolean {
|
||||
return (
|
||||
window.matchMedia(MOBILE_MQ).matches ||
|
||||
window.matchMedia(TOUCH_MQ).matches
|
||||
)
|
||||
}
|
||||
|
||||
function getInitialLayout(): IdeLayout {
|
||||
const stored = localStorage.getItem('pdf.layout')
|
||||
// Check mobile first — must come before the stored-'flat' check so that a
|
||||
// stale 'flat' (written by an old autoSave race) doesn't block the mobile
|
||||
// default. isMobileDevice() also catches spoofed-viewport browsers.
|
||||
if (isMobileDevice()) return 'verticalSplit'
|
||||
if (stored === 'flat') return 'flat'
|
||||
if (stored === 'split') return 'sideBySide'
|
||||
if (stored === 'vertical') return 'verticalSplit'
|
||||
return 'sideBySide'
|
||||
}
|
||||
|
||||
const reviewPanelStorageKey = `ui.reviewPanelOpen.${getMeta('ol-project_id')}`
|
||||
|
||||
export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
@@ -192,14 +221,17 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
)
|
||||
|
||||
// whether to display the editor and preview side-by-side or full-width ("flat")
|
||||
const [pdfLayout, setPdfLayout] = useState<IdeLayout>('sideBySide')
|
||||
const [pdfLayout, setPdfLayout] = useState<IdeLayout>(getInitialLayout)
|
||||
|
||||
// whether stylesheet on theme is loading
|
||||
const [loadingStyleSheet, setLoadingStyleSheet] = useState(false)
|
||||
|
||||
const changeLayout = useCallback(
|
||||
(newLayout: IdeLayout, newView: IdeView = 'editor') => {
|
||||
const targetView = newLayout === 'sideBySide' ? 'editor' : newView
|
||||
const targetView =
|
||||
newLayout === 'sideBySide' || newLayout === 'verticalSplit'
|
||||
? 'editor'
|
||||
: newView
|
||||
setPdfLayout(newLayout)
|
||||
if (targetView === 'editor') {
|
||||
restoreView()
|
||||
@@ -230,7 +262,10 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
} = useDetachLayout()
|
||||
|
||||
const pdfPreviewOpen =
|
||||
pdfLayout === 'sideBySide' || view === 'pdf' || detachRole === 'detacher'
|
||||
pdfLayout === 'sideBySide' ||
|
||||
pdfLayout === 'verticalSplit' ||
|
||||
view === 'pdf' ||
|
||||
detachRole === 'detacher'
|
||||
|
||||
useEffect(() => {
|
||||
if (debugPdfDetach) {
|
||||
|
||||
@@ -13,7 +13,7 @@ function getTheme(
|
||||
if (isIEEEBranded()) {
|
||||
return 'dark'
|
||||
}
|
||||
if (overallTheme === 'light-') {
|
||||
if (overallTheme === 'light-' || overallTheme === 'lumiere-') {
|
||||
return 'light'
|
||||
}
|
||||
if (overallTheme === 'system') {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user