Compare commits
150 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0caa4ff144 | |||
| af906d90b9 | |||
| c041719e6a | |||
| dbb519835a | |||
| 55ad9af7da | |||
| be8aef44fe | |||
| 9a474f7790 | |||
| 8ea6f6ecb1 | |||
| 0e18230d9a | |||
| 9c97e7c01a | |||
| e1533d979f | |||
| d5de6550d4 | |||
| 71f1b928e9 | |||
| f22ee608a7 | |||
| 9f1c9babf7 | |||
| 44a9adcea1 | |||
| a17d21c0ca | |||
| a7a569303c | |||
| 8214ca6121 | |||
| 4f5dad383b | |||
| eedf4b50f6 | |||
| c6d71e58b7 | |||
| 06085cda21 | |||
| 8515a899ac | |||
| 200bff4ecb | |||
| b5cf5f9e7b | |||
| 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.
|
# (CE default): admin creates accounts / sends invites.
|
||||||
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
|
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
|
||||||
value: "true"
|
value: "true"
|
||||||
|
- name: OVERLEAF_LATEX_SHELL_ESCAPE
|
||||||
|
value: "true"
|
||||||
# (SMTP email vars are loaded below via envFrom.)
|
# (SMTP email vars are loaded below via envFrom.)
|
||||||
# SMTP for password-reset / invite emails. All
|
# SMTP for password-reset / invite emails. All
|
||||||
# OVERLEAF_EMAIL_* vars come from the optional 'verso-smtp'
|
# OVERLEAF_EMAIL_* vars come from the optional 'verso-smtp'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- git-bridge-test
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -19,7 +20,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
kubectl -n ci delete job verso-buildkit --ignore-not-found=true --wait=true
|
kubectl -n ci delete job verso-buildkit --ignore-not-found=true --wait=true
|
||||||
|
|
||||||
cat <<'EOF' | kubectl apply -f -
|
BRANCH=${GITEA_REF_NAME:-${GITHUB_REF_NAME:-main}}
|
||||||
|
cat <<'EOF' | sed "s|__BRANCH__|${BRANCH}|g" | kubectl apply -f -
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
kind: Job
|
kind: Job
|
||||||
metadata:
|
metadata:
|
||||||
@@ -38,7 +40,7 @@ jobs:
|
|||||||
- |
|
- |
|
||||||
set -eux
|
set -eux
|
||||||
REG=registry.git.svc.cluster.local:5000
|
REG=registry.git.svc.cluster.local:5000
|
||||||
git clone --depth 1 https://git.alocoq.fr/alois/verso.git /workspace/repo
|
git clone --depth 1 --branch __BRANCH__ https://git.alocoq.fr/alois/verso.git /workspace/repo
|
||||||
|
|
||||||
# (#1) Build the base image only when it actually changes.
|
# (#1) Build the base image only when it actually changes.
|
||||||
# The base layers' only repo input is Dockerfile-base, so
|
# The base layers' only repo input is Dockerfile-base, so
|
||||||
@@ -300,6 +302,10 @@ jobs:
|
|||||||
# link-sharing users).
|
# link-sharing users).
|
||||||
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
|
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
|
||||||
value: "true"
|
value: "true"
|
||||||
|
- name: OVERLEAF_LATEX_SHELL_ESCAPE
|
||||||
|
value: "true"
|
||||||
|
- name: OVERLEAF_ENABLE_GIT_SYNC
|
||||||
|
value: "true"
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
|
|||||||
@@ -2,73 +2,182 @@
|
|||||||
<img src="services/web/public/img/ol-brand/verso-logo.svg" alt="Verso" width="440">
|
<img src="services/web/public/img/ol-brand/verso-logo.svg" alt="Verso" width="440">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**A collaborative, real-time editor for Quarto, LaTeX and Typst — documents and presentations.**
|
**A collaborative, real-time editor for LaTeX, Quarto and Typst — self-hosted.**
|
||||||
|
|
||||||
Verso is a fork of [Overleaf](https://github.com/overleaf/overleaf) that adds
|
---
|
||||||
first-class [Quarto](https://quarto.org) and [Typst](https://typst.app) support
|
|
||||||
alongside Overleaf's LaTeX toolchain. It keeps Overleaf's real-time
|
|
||||||
collaboration infrastructure and runs **three compilers side by side**, chosen
|
|
||||||
automatically from the root file's extension:
|
|
||||||
|
|
||||||
| Root file | Compiler | Typical output |
|
## What is Verso?
|
||||||
|-----------|----------|----------------|
|
|
||||||
| `.qmd` | Quarto | PDF (via Typst or LaTeX), or an HTML/RevealJS deck |
|
|
||||||
| `.tex` | `latexmk` / TeX Live | PDF |
|
|
||||||
| `.typ` | Typst | PDF |
|
|
||||||
|
|
||||||
All three coexist on one server; no per-project configuration is required to
|
Verso is a fork of [Overleaf](https://github.com/overleaf/overleaf) that extends its
|
||||||
pick the engine.
|
collaborative editing infrastructure to support [Quarto](https://quarto.org) and
|
||||||
|
[Typst](https://typst.app) projects alongside LaTeX. Think of it as Overleaf, but
|
||||||
|
not limited to LaTeX.
|
||||||
|
|
||||||
|
### Verso vs Overleaf
|
||||||
|
|
||||||
|
[Overleaf](https://www.overleaf.com) is the gold standard for collaborative LaTeX
|
||||||
|
editing. Verso keeps everything that makes Overleaf great — real-time co-editing,
|
||||||
|
operational-transformation history, auth, project management, file storage — and
|
||||||
|
adds:
|
||||||
|
|
||||||
|
- **Quarto and Typst compilers** running alongside TeX Live, dispatched
|
||||||
|
automatically from the root file's extension (`.qmd` → Quarto, `.typ` → Typst,
|
||||||
|
`.tex` → `latexmk`).
|
||||||
|
- **Language-aware editor** for Quarto and Typst (syntax highlighting, completions,
|
||||||
|
document outline) — not just LaTeX.
|
||||||
|
- **Publish & share compiled output** (`/p/:token` with tiered access links) — a
|
||||||
|
feature absent from Overleaf Community Edition.
|
||||||
|
- **Lumière theme** — a redesigned project dashboard and editor chrome with a
|
||||||
|
card-based grid, thumbnails, and a teal gradient identity.
|
||||||
|
- **Full i18n** — French, German, Italian, and Spanish UI translations on top of
|
||||||
|
Overleaf's English base.
|
||||||
|
- Completely **free and self-hosted**; no Overleaf subscription required.
|
||||||
|
|
||||||
|
### Verso vs Quarto
|
||||||
|
|
||||||
|
[Quarto](https://quarto.org) is a command-line tool: you install it locally, write
|
||||||
|
`.qmd` files in any text editor, and run `quarto render` in a terminal. It is
|
||||||
|
excellent for solo authors with full control over their environment.
|
||||||
|
|
||||||
|
Verso wraps Quarto in a collaborative web editor:
|
||||||
|
|
||||||
|
- **No local install** — Quarto, Typst, TeX Live and Python run on the server.
|
||||||
|
- **Real-time collaboration** — multiple people edit the same `.qmd` simultaneously
|
||||||
|
with live cursors and conflict-free merging.
|
||||||
|
- **Not just Quarto** — LaTeX and Typst projects live in the same workspace, under
|
||||||
|
the same auth and history system.
|
||||||
|
- **Publish in one click** — RevealJS decks and PDFs are served at a stable link
|
||||||
|
without leaving the browser.
|
||||||
|
|
||||||
|
Verso is not a replacement for Quarto's CLI — it is a platform that makes Quarto
|
||||||
|
accessible as a shared, always-on service.
|
||||||
|
|
||||||
|
### Verso vs Typst.app
|
||||||
|
|
||||||
|
[Typst.app](https://typst.app) is a cloud-hosted web editor for Typst. It is
|
||||||
|
polished and fast, but it is a proprietary SaaS product and only supports Typst.
|
||||||
|
|
||||||
|
Verso differs in that:
|
||||||
|
|
||||||
|
- It is **self-hosted** and open-source (AGPL v3) — you control your data.
|
||||||
|
- It supports **three languages** (Typst, LaTeX, Quarto) in one instance.
|
||||||
|
- Real-time collaboration is powered by **operational transformation** (the same
|
||||||
|
engine as Overleaf), not CRDTs, which means it handles concurrent edits
|
||||||
|
gracefully for long documents.
|
||||||
|
- It ships with a full **project history** and version-restore workflow.
|
||||||
|
|
||||||
|
If you only need Typst and want a lighter, Typst-focused alternative, have a look
|
||||||
|
at **[Collabst](https://github.com/herluf-ba/collabst)** — an open-source,
|
||||||
|
self-hosted collaborative Typst editor that is independent of the Overleaf
|
||||||
|
codebase and shows a lot of promise.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Real-time collaboration** — multiple people editing the same file at once,
|
- **Real-time collaboration** — multiple editors, live cursors, full project history and version restore.
|
||||||
powered by Overleaf's operational-transformation engine, with live cursors
|
- **Three compilers, auto-dispatched** by root file extension:
|
||||||
and full project history.
|
| Root file | Compiler | Typical output |
|
||||||
- **Three compilers, auto-dispatched** — Quarto, LaTeX and Typst projects live
|
|-----------|----------|----------------|
|
||||||
side by side; the runner is selected from the root file's extension.
|
| `.qmd` | Quarto | PDF (via Typst or LaTeX), HTML, or RevealJS |
|
||||||
- **Language-aware editor for all three**:
|
| `.tex` | `latexmk` / TeX Live | PDF |
|
||||||
- *LaTeX* — syntax highlighting, command/environment/reference autocomplete,
|
| `.typ` | Typst | PDF |
|
||||||
linting (inherited from Overleaf).
|
- **Language-aware editor for all three** — syntax highlighting, completions, and a document outline panel for LaTeX, Quarto and Typst.
|
||||||
- *Quarto (`.qmd`)* — Markdown highlighting plus Quarto-aware completions:
|
- **Format badge** on the project dashboard; compiler dropdown greys out inapplicable engines.
|
||||||
code chunks (```` ```{python} ````, `{r}`, `{julia}`, `{ojs}`…), callouts
|
- **Publish & share** — compile and snapshot to `/p/:token` with three independent access tiers (project members / any logged-in user / public). HTML/RevealJS decks are served live; PDFs are embedded inline. A **Present** toolbar button links directly to the published deck.
|
||||||
and fenced divs (`::: {.callout-note}`, columns, tabsets) and
|
- **RevealJS thumbnails** — the first slide of a presentation is rendered as a preview card in the project list.
|
||||||
cross-references (`@fig-`, `@tbl-`, `@sec-`, `@eq-`).
|
- **Quarto Python cells** — optional per-project virtual environment built from `requirements.txt`, so Python code chunks execute during render.
|
||||||
- *Typst (`.typ`)* — syntax highlighting and completions for the common
|
- **Visual formatting toolbar** — bold, italic, headings and inline code shortcuts for Quarto (`.qmd`) and Typst (`.typ`) files, in addition to Overleaf's existing LaTeX toolbar.
|
||||||
functions and markup (`#import`, `#let`, `#set`, `#show`, `#figure`,
|
- **Lumière theme** — card-based project dashboard with PDF/slide thumbnails, a teal gradient identity, dark editor chrome, and an XS compact list view.
|
||||||
`#table`, `#cite`, …).
|
- **i18n** — French, German, Italian and Spanish UI translations.
|
||||||
- **Document outline** — section headings are extracted into the sidebar
|
- **Auto-compile** — preview refreshes automatically after you stop typing.
|
||||||
outline panel for LaTeX, Quarto (`#`, `##`, …) and Typst (`=`, `==`, …).
|
|
||||||
- **Format at a glance** — the project dashboard shows a per-project format
|
|
||||||
badge (Quarto / Typst / LaTeX), and the compiler dropdown greys out engines
|
|
||||||
that don't apply to the current root file.
|
|
||||||
- **Publish & share compiled output** — publish the compiled result as a
|
|
||||||
standalone page at `/p/:token`, with three independent access tiers (project
|
|
||||||
members / any logged-in user / public). Works for both HTML/RevealJS decks
|
|
||||||
(served live) and PDFs (embedded inline). HTML decks also get a one-click
|
|
||||||
**Present** button in the toolbar.
|
|
||||||
- **Quarto Python cells** — optional per-project virtual environment built from
|
|
||||||
the project's `requirements.txt`, so Python code chunks run during render
|
|
||||||
(gated to the project owner and invited collaborators).
|
|
||||||
- **Auto-compile** — the preview refreshes automatically shortly after you stop
|
|
||||||
typing.
|
|
||||||
|
|
||||||
## Output formats
|
---
|
||||||
|
|
||||||
In the YAML frontmatter of a `.qmd` file:
|
## Releases
|
||||||
|
|
||||||
```yaml
|
### Alpha 1
|
||||||
format: typst # → PDF preview, rendered via Typst (no LaTeX required)
|
|
||||||
format: pdf # → PDF preview, rendered via LaTeX
|
|
||||||
format: revealjs # → interactive HTML slideshow preview
|
|
||||||
format: html # → a static HTML page
|
|
||||||
```
|
|
||||||
|
|
||||||
Typst ships inside Quarto, so `format: typst` needs no separate installation.
|
The initial public release. Established Verso as an Overleaf fork with first-class
|
||||||
|
multi-language support:
|
||||||
|
|
||||||
> **Note on display math**: keep `$$ … $$` blocks on a single line. Multi-line
|
- Quarto (`.qmd`) and Typst (`.typ`) compilers running alongside TeX Live,
|
||||||
> display-math blocks can trigger YAML parse errors in some Quarto versions.
|
dispatched automatically by root file extension — no per-project configuration.
|
||||||
|
- Language-aware editor for Quarto: Markdown highlighting, code-chunk completions
|
||||||
|
(`{python}`, `{r}`, `{julia}`, `{ojs}`…), callout and fenced-div completions,
|
||||||
|
cross-reference completions (`@fig-`, `@tbl-`, `@sec-`…).
|
||||||
|
- Language-aware editor for Typst: syntax highlighting and completions for
|
||||||
|
functions, imports, math and markup.
|
||||||
|
- Document outline panel for all three languages (LaTeX `\section`, Quarto `#`,
|
||||||
|
Typst `=`).
|
||||||
|
- Format badge on the project dashboard; compiler selector greys out inapplicable
|
||||||
|
engines for the current root file.
|
||||||
|
- Publish & share compiled output — HTML/RevealJS decks and PDFs hosted at
|
||||||
|
`/p/:token` with tiered access links (project / logged-in / public), each
|
||||||
|
independently resettable.
|
||||||
|
- Quarto Python code-cell execution via an optional per-project `requirements.txt`
|
||||||
|
virtual environment.
|
||||||
|
- Verso branding: name, logo and Kubernetes production deploy workflow.
|
||||||
|
|
||||||
|
### Alpha 2
|
||||||
|
|
||||||
|
Refinements to the Typst editor and the format badge system:
|
||||||
|
|
||||||
|
- **Quarto format sub-types** — the project badge now distinguishes *Quarto PDF*
|
||||||
|
from *Quarto Slides*, reading the frontmatter `format:` to pick the right label.
|
||||||
|
- **Python packages for collaborators** — Quarto Python package installation
|
||||||
|
extended to all users who have write access to the project, not only the owner.
|
||||||
|
- **Typst syntax highlighting overhaul** — complete grammar rewrite covering:
|
||||||
|
function calls and named argument keys, multi-line display math, `#{…}` code
|
||||||
|
blocks, content blocks, `show`-rule bodies, `let`-value bindings, and keyword
|
||||||
|
vs identifier disambiguation.
|
||||||
|
- **Typst visual formatting** — bold and italic toolbar buttons and keyboard
|
||||||
|
shortcuts (`Ctrl+B`, `Ctrl+I`), plus underline, small-caps and hyperlink
|
||||||
|
buttons, matching the Quarto and LaTeX toolbar experience.
|
||||||
|
|
||||||
|
### Alpha 3
|
||||||
|
|
||||||
|
- **Lumière theme** — redesigned project dashboard with a card grid, PDF/slide
|
||||||
|
thumbnails, parallax hover effects, a teal gradient identity and a dark editor
|
||||||
|
chrome. Includes an XS compact list view and a tile zoom slider.
|
||||||
|
- **Full i18n** — French, German, Italian and Spanish translations covering the
|
||||||
|
complete UI (login, dashboard, editor, settings, emails).
|
||||||
|
- **Visual editors for Quarto and Typst** — bold, italic, headings and inline code
|
||||||
|
shortcuts in the toolbar for `.qmd` and `.typ` files.
|
||||||
|
- **Top/bottom split view** — new editor layout that stacks the source editor
|
||||||
|
above the PDF preview vertically, in addition to the existing side-by-side mode.
|
||||||
|
- **Bidirectional format export** — LaTeX projects can be converted to Typst and
|
||||||
|
Typst projects to LaTeX via pandoc. Available from the File menu in the editor.
|
||||||
|
- **Mobile layout** — project dashboard, search bar, footer and editor all
|
||||||
|
adapted for phone screen sizes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known issues
|
||||||
|
|
||||||
|
- **Large file upload timeouts** — uploads of large files on slow connections
|
||||||
|
can time out at the proxy layer. A streaming response fix is pending.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security model — trusted environments only
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> Verso is designed for **closed groups of trusted users** (a lab, a class, a
|
||||||
|
> small team). All three compilers can execute arbitrary code on the server:
|
||||||
|
>
|
||||||
|
> - LaTeX with shell-escape enabled can run system commands.
|
||||||
|
> - Quarto Python cells execute Python code directly.
|
||||||
|
> - Typst's scripting layer is sandboxed by design, but runs server-side.
|
||||||
|
>
|
||||||
|
> There is **no per-project sandbox or resource isolation** beyond what the
|
||||||
|
> operating system provides. Exposing Verso to the public internet with open
|
||||||
|
> registration is not recommended. If you need to host a collaborative
|
||||||
|
> LaTeX editor for untrusted users or at scale, look at
|
||||||
|
> [Overleaf's non-Community offerings](https://www.overleaf.com/for/enterprises),
|
||||||
|
> which include proper sandboxing and enterprise access controls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -82,24 +191,23 @@ docker run -d \
|
|||||||
registry.alocoq.fr/verso:latest
|
registry.alocoq.fr/verso:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost` in your browser, then visit `/launchpad` on first run to
|
Open `http://localhost`, then visit `/launchpad` on first run to create the admin
|
||||||
create the admin account.
|
account.
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the base image (system deps + Quarto + TeX Live)
|
|
||||||
cd server-ce
|
cd server-ce
|
||||||
make build-base
|
make build-base # base OS image: system deps, Quarto, Typst, TeX Live
|
||||||
|
make build-community # application image: Node services + compiled frontend
|
||||||
# Build the application image
|
|
||||||
make build-community
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `server-ce/Dockerfile-base` | Base OS image — system deps, Quarto (with Typst) and a TeX Live (`latexmk`) toolchain |
|
| `server-ce/Dockerfile-base` | Base image — system deps, Quarto (with Typst) and TeX Live |
|
||||||
| `server-ce/Dockerfile` | Application image — Node services and the compiled frontend |
|
| `server-ce/Dockerfile` | App image — Node services and the compiled React frontend |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -108,10 +216,10 @@ single container managed by `runit`, with `nginx` as the front router.
|
|||||||
|
|
||||||
```
|
```
|
||||||
browser ──→ nginx:80
|
browser ──→ nginx:80
|
||||||
├── / ──────────────────→ web:4000 (main app, React UI)
|
├── / ──────────────────→ web:4000 (main app, React UI)
|
||||||
├── /socket.io ──────────→ real-time:3026 (WebSocket, OT engine)
|
├── /socket.io ──────────→ real-time:3026 (WebSocket, OT engine)
|
||||||
├── /p/:token ───────────→ web (published output)
|
├── /p/:token ───────────→ web (published output)
|
||||||
└── /project/*/output/* → clsi-nginx:8080 (compiled output files)
|
└── /project/*/output/* → clsi-nginx:8080 (compiled output files)
|
||||||
|
|
||||||
web → document-updater → Redis pub/sub → real-time → browser
|
web → document-updater → Redis pub/sub → real-time → browser
|
||||||
web → CLSI (quarto render / latexmk / typst) → output files → nginx → browser
|
web → CLSI (quarto render / latexmk / typst) → output files → nginx → browser
|
||||||
@@ -122,64 +230,12 @@ web → CLSI (quarto render / latexmk / typst) → output files → nginx → br
|
|||||||
| `web` | HTTP API, React frontend, auth, project & sharing management |
|
| `web` | HTTP API, React frontend, auth, project & sharing management |
|
||||||
| `real-time` | WebSocket layer, live cursors and edit sync |
|
| `real-time` | WebSocket layer, live cursors and edit sync |
|
||||||
| `document-updater` | Operational transformation, Redis pub/sub |
|
| `document-updater` | Operational transformation, Redis pub/sub |
|
||||||
| `clsi` | Compiler — runs `quarto render` (`.qmd`), `latexmk` (`.tex`) or `typst` (`.typ`) and serves output |
|
| `clsi` | Compiler — runs `quarto render`, `latexmk` or `typst` and serves output |
|
||||||
| `docstore` | Document text storage (MongoDB) |
|
| `docstore` | Document text storage (MongoDB) |
|
||||||
| `filestore` | Binary file storage (S3 or local) |
|
| `filestore` | Binary file storage (S3 or local) |
|
||||||
| `project-history` | Change history and version tracking |
|
| `project-history` | Change history and version tracking |
|
||||||
|
|
||||||
## Writing documents
|
|
||||||
|
|
||||||
### Quarto (`main.qmd`)
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
---
|
||||||
title: My Presentation
|
|
||||||
author: Your Name
|
|
||||||
date: today
|
|
||||||
format: revealjs
|
|
||||||
---
|
|
||||||
|
|
||||||
## Slide one
|
|
||||||
|
|
||||||
Write **Markdown** here.
|
|
||||||
|
|
||||||
## Mathematics
|
|
||||||
|
|
||||||
$$\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}$$
|
|
||||||
```
|
|
||||||
|
|
||||||
Switch `format: revealjs` to `format: typst` (or `pdf`) for a PDF preview.
|
|
||||||
|
|
||||||
### LaTeX (`main.tex`)
|
|
||||||
|
|
||||||
LaTeX works exactly as in Overleaf: a project whose root file is a `.tex` file
|
|
||||||
compiles with `latexmk`/TeX Live, no setting required. The **Example LaTeX
|
|
||||||
project** in the *New project* menu is a ready-made starting point.
|
|
||||||
|
|
||||||
> The bundled TeX Live is a minimal install. Documents that need extra packages
|
|
||||||
> may not build out of the box — see `server-ce/Dockerfile-base` for how to
|
|
||||||
> switch to a fuller TeX Live scheme.
|
|
||||||
|
|
||||||
### Typst (`main.typ`)
|
|
||||||
|
|
||||||
A project whose root file is a `.typ` file compiles directly to PDF with
|
|
||||||
[Typst](https://typst.app) — fast, modern markup with a real scripting
|
|
||||||
language. Verso drives the Typst bundled with Quarto, so no extra install is
|
|
||||||
needed. Use the **Blank Typst project** entry in the *New project* menu to get
|
|
||||||
started.
|
|
||||||
|
|
||||||
## Publishing compiled output
|
|
||||||
|
|
||||||
From **Share → Publish**, Verso compiles the project and snapshots the result to
|
|
||||||
a standalone page at `/p/:token`:
|
|
||||||
|
|
||||||
- **HTML / RevealJS** decks are served as a live page (the **Present** toolbar
|
|
||||||
button is a one-click shortcut to this).
|
|
||||||
- **PDF** output is embedded inline; the raw file stays reachable at
|
|
||||||
`/p/:token/output.pdf`.
|
|
||||||
|
|
||||||
Three stable links are issued, one per access tier — project members, any
|
|
||||||
logged-in user, or anyone — and each can be copied or independently reset.
|
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
@@ -195,35 +251,42 @@ The most commonly needed:
|
|||||||
| `OVERLEAF_SITE_URL` | — | Public URL (used in emails and published links) |
|
| `OVERLEAF_SITE_URL` | — | Public URL (used in emails and published links) |
|
||||||
| `OVERLEAF_SITE_LANGUAGE` | `en` | Default UI language (e.g. `fr`) |
|
| `OVERLEAF_SITE_LANGUAGE` | `en` | Default UI language (e.g. `fr`) |
|
||||||
| `OVERLEAF_ENABLE_PROJECT_PYTHON_VENV` | `false` | Allow Quarto Python cells to use a project `requirements.txt` |
|
| `OVERLEAF_ENABLE_PROJECT_PYTHON_VENV` | `false` | Allow Quarto Python cells to use a project `requirements.txt` |
|
||||||
| `OVERLEAF_ADMIN_EMAIL` | — | Email for the first admin account |
|
| `OVERLEAF_ADMIN_EMAIL` | — | Email shown on the launchpad for the first admin account |
|
||||||
|
|
||||||
See the [Overleaf Server documentation](https://github.com/overleaf/overleaf/wiki)
|
See the [Overleaf Server documentation](https://github.com/overleaf/overleaf/wiki)
|
||||||
for the full list.
|
for the full list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Relation to Overleaf
|
## Relation to Overleaf
|
||||||
|
|
||||||
Verso is a fork of [Overleaf Community Edition](https://github.com/overleaf/overleaf).
|
Verso is a fork of [Overleaf Community Edition](https://github.com/overleaf/overleaf).
|
||||||
The main additions on top of upstream are:
|
Everything that Overleaf CE provides — real-time collaboration, operational-transformation
|
||||||
|
history, auth, project management, binary file storage — is inherited unchanged. The
|
||||||
|
Verso-specific additions are listed in the Features section and tracked across releases above.
|
||||||
|
|
||||||
- Quarto and Typst compilers running alongside LaTeX, dispatched by the root
|
Verso is not affiliated with Overleaf Ltd.
|
||||||
file's extension.
|
|
||||||
- Editor language support (highlighting, autocomplete, outline) for Quarto and
|
|
||||||
Typst.
|
|
||||||
- A per-project format badge on the dashboard and a root-file-aware compiler
|
|
||||||
selector.
|
|
||||||
- Publishing/sharing of compiled output (HTML decks and PDFs) via `/p/:token`
|
|
||||||
with tiered access links, and a toolbar **Present** shortcut.
|
|
||||||
- Optional per-project Python virtual environments for Quarto code execution.
|
|
||||||
- Verso branding (name, logo, palette, loading animation).
|
|
||||||
|
|
||||||
All other infrastructure — real-time collaboration, history, auth, file
|
---
|
||||||
storage, project management — is unchanged from Overleaf.
|
|
||||||
|
|
||||||
## Contributing
|
## Supporting the ecosystem
|
||||||
|
|
||||||
Contributions are welcome — open an issue or pull request on the
|
Verso is not accepting contributions or donations at this time. If you find it
|
||||||
[Verso repository](https://git.alocoq.fr/alois/verso). The upstream Overleaf
|
useful and want to support the broader ecosystem it builds on:
|
||||||
contribution guidelines are in [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
||||||
|
- **Support Overleaf** — Verso is built on Overleaf's infrastructure. The best
|
||||||
|
way to support their work is to use or subscribe to
|
||||||
|
[Overleaf](https://www.overleaf.com) and encourage your institution to do the
|
||||||
|
same.
|
||||||
|
- **Support Typst** — [Typst GmbH](https://typst.app) is the company behind the
|
||||||
|
Typst compiler. Using Typst.app or sponsoring the
|
||||||
|
[Typst project on GitHub](https://github.com/typst/typst) helps sustain the
|
||||||
|
language itself.
|
||||||
|
- **Support RevealJS** — Verso uses [Reveal.js](https://revealjs.com) for
|
||||||
|
HTML presentations. Consider sponsoring the
|
||||||
|
[RevealJS project on GitHub](https://github.com/hakimel/reveal.js).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -39,3 +39,12 @@ Ideas and features deferred from the current alpha.
|
|||||||
- **Quarto RevealJS**: insert slide divider (`---`), insert speaker
|
- **Quarto RevealJS**: insert slide divider (`---`), insert speaker
|
||||||
notes (`::: notes`), insert columns layout, insert video embed
|
notes (`::: notes`), insert columns layout, insert video embed
|
||||||
(using Quarto's `{{< video >}}` shortcode).
|
(using Quarto's `{{< video >}}` shortcode).
|
||||||
|
|
||||||
|
### AI writing assistant
|
||||||
|
|
||||||
|
- **In-editor AI assistant** — Inline writing help similar to what Overleaf
|
||||||
|
and CoCalc offer: suggest completions, rephrase selections, explain LaTeX
|
||||||
|
errors, and generate boilerplate (figures, tables, equations). Should work
|
||||||
|
across all three formats (`.tex`, `.typ`, `.qmd`). Backend would proxy
|
||||||
|
requests to a configurable model API (Claude, OpenAI-compatible) so
|
||||||
|
self-hosters can bring their own key.
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ RUN --mount=type=cache,target=/root/.cache \
|
|||||||
# Add the actual source files
|
# Add the actual source files
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
COPY --parents libraries/ services/ tools/migrations/ /overleaf/
|
COPY --parents libraries/ services/ tools/migrations/ /overleaf/
|
||||||
|
|
||||||
|
# Syntax-check all server-side ESM modules before the expensive webpack
|
||||||
|
# compile. node --check parses without executing, so it's fast and safe.
|
||||||
|
# Catches things like escaped backticks from sed substitutions that webpack
|
||||||
|
# never sees (it only bundles frontend code).
|
||||||
|
RUN find services/web/app/src services/web/modules -name '*.mjs' | xargs node --check
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache \
|
RUN --mount=type=cache,target=/root/.cache \
|
||||||
--mount=type=cache,target=/root/.yarn/berry/cache,id=server-ce-yarn-cache \
|
--mount=type=cache,target=/root/.yarn/berry/cache,id=server-ce-yarn-cache \
|
||||||
--mount=type=tmpfs,target=/usr/local/share/.cache/yarn \
|
--mount=type=tmpfs,target=/usr/local/share/.cache/yarn \
|
||||||
@@ -68,6 +75,10 @@ RUN --mount=type=cache,target=/root/.cache \
|
|||||||
echo "==== PACK LOGS (all attempts failed) ===="; \
|
echo "==== PACK LOGS (all attempts failed) ===="; \
|
||||||
find /tmp -name pack.log -exec cat {} \; 2>/dev/null || true; \
|
find /tmp -name pack.log -exec cat {} \; 2>/dev/null || true; \
|
||||||
exit 1
|
exit 1
|
||||||
|
# Ensure git is available for the git sync feature
|
||||||
|
# -------------------------------------------------
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy runit service startup scripts to its location
|
# Copy runit service startup scripts to its location
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
ADD server-ce/runit /etc/service
|
ADD server-ce/runit /etc/service
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
|||||||
unattended-upgrades \
|
unattended-upgrades \
|
||||||
build-essential wget net-tools unzip time poppler-utils optipng strace nginx git python3 python-is-python3 zlib1g-dev libpcre3-dev gettext-base libwww-perl ca-certificates curl gnupg \
|
build-essential wget net-tools unzip time poppler-utils optipng strace nginx git python3 python-is-python3 zlib1g-dev libpcre3-dev gettext-base libwww-perl ca-certificates curl gnupg \
|
||||||
qpdf \
|
qpdf \
|
||||||
|
pandoc \
|
||||||
# upgrade base-image, batch all the upgrades together, rather than installing them on-by-one (which is slow!)
|
# upgrade base-image, batch all the upgrades together, rather than installing them on-by-one (which is slow!)
|
||||||
&& unattended-upgrade --verbose --no-minimal-upgrade-steps \
|
&& unattended-upgrade --verbose --no-minimal-upgrade-steps \
|
||||||
# install Node.js https://github.com/nodesource/distributions#nodejs
|
# install Node.js https://github.com/nodesource/distributions#nodejs
|
||||||
@@ -104,6 +105,15 @@ RUN apt-get update \
|
|||||||
opencv-python-headless tqdm \
|
opencv-python-headless tqdm \
|
||||||
&& rm -rf /var/lib/apt/lists/* /root/.cache
|
&& rm -rf /var/lib/apt/lists/* /root/.cache
|
||||||
|
|
||||||
|
# Install Inkscape (for the LaTeX svg package via shell-escape)
|
||||||
|
# Must come AFTER the pip installs above: inkscape pulls in python3-numpy via
|
||||||
|
# apt, which would block pip from upgrading numpy. With pip's numpy already in
|
||||||
|
# /usr/local/lib, apt installs its own copy into /usr/lib alongside it — no
|
||||||
|
# conflict — and Python resolves /usr/local/lib first at import time.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y inkscape \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install decktape + headless Chromium (for exporting RevealJS decks to PDF)
|
# Install decktape + headless Chromium (for exporting RevealJS decks to PDF)
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# decktape drives a headless Chromium (via Puppeteer) to print the rendered
|
# decktape drives a headless Chromium (via Puppeteer) to print the rendered
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export ENABLE_PANDOC_CONVERSIONS=true
|
||||||
export CHAT_HOST=127.0.0.1
|
export CHAT_HOST=127.0.0.1
|
||||||
export CLSI_HOST=127.0.0.1
|
export CLSI_HOST=127.0.0.1
|
||||||
export DOCSTORE_HOST=127.0.0.1
|
export DOCSTORE_HOST=127.0.0.1
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const TMP_DIR = '/var/lib/overleaf/tmp'
|
|||||||
const settings = {
|
const settings = {
|
||||||
clsi: {
|
clsi: {
|
||||||
optimiseInDocker: process.env.OPTIMISE_PDF === 'true',
|
optimiseInDocker: process.env.OPTIMISE_PDF === 'true',
|
||||||
|
latexShellEscape: process.env.OVERLEAF_LATEX_SHELL_ESCAPE === 'true',
|
||||||
},
|
},
|
||||||
|
|
||||||
brandPrefix: '',
|
brandPrefix: '',
|
||||||
@@ -57,6 +58,8 @@ const settings = {
|
|||||||
allowAnonymousReadAndWriteSharing:
|
allowAnonymousReadAndWriteSharing:
|
||||||
process.env.OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING === 'true',
|
process.env.OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING === 'true',
|
||||||
|
|
||||||
|
enableGitSync: process.env.OVERLEAF_ENABLE_GIT_SYNC === 'true',
|
||||||
|
|
||||||
// Databases
|
// Databases
|
||||||
// ---------
|
// ---------
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ spec:
|
|||||||
# them). Public self-registration is also off (CE default).
|
# them). Public self-registration is also off (CE default).
|
||||||
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
|
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
|
||||||
value: "true"
|
value: "true"
|
||||||
|
- name: OVERLEAF_ENABLE_GIT_SYNC
|
||||||
|
value: "true"
|
||||||
# SMTP for password-reset / invite emails. All OVERLEAF_EMAIL_* vars
|
# SMTP for password-reset / invite emails. All OVERLEAF_EMAIL_* vars
|
||||||
# are loaded from the optional 'verso-smtp' Secret — its keys must be
|
# are loaded from the optional 'verso-smtp' Secret — its keys must be
|
||||||
# named exactly like these env vars (see the kubectl create secret
|
# named exactly like these env vars (see the kubectl create secret
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ server {
|
|||||||
rewrite ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ /$4 break;
|
rewrite ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ /$4 break;
|
||||||
root /var/lib/overleaf/data/output/$1-$2/generated-files/$3/;
|
root /var/lib/overleaf/data/output/$1-$2/generated-files/$3/;
|
||||||
}
|
}
|
||||||
# handle output files for anonymous users
|
# handle output files for anonymous users (project ID may be a UUID with hyphens for conversions)
|
||||||
location ~ ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ {
|
location ~ ^/project/([0-9a-f-]+)/build/([0-9a-f-]+)/output/(.+)$ {
|
||||||
rewrite ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ /$3 break;
|
rewrite ^/project/([0-9a-f-]+)/build/([0-9a-f-]+)/output/(.+)$ /$3 break;
|
||||||
root /var/lib/overleaf/data/output/$1/generated-files/$2/;
|
root /var/lib/overleaf/data/output/$1/generated-files/$2/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ http {
|
|||||||
gzip_proxied any; # allow upstream server to compress.
|
gzip_proxied any; # allow upstream server to compress.
|
||||||
|
|
||||||
client_max_body_size 500m;
|
client_max_body_size 500m;
|
||||||
|
client_body_timeout 15m;
|
||||||
|
|
||||||
# gzip_vary on;
|
# gzip_vary on;
|
||||||
# gzip_proxied any;
|
# gzip_proxied any;
|
||||||
|
|||||||
@@ -9,6 +9,26 @@ server {
|
|||||||
internal;
|
internal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# File upload endpoints: extended timeouts for large files on slow connections.
|
||||||
|
# proxy_request_buffering off: forward the request body to Node.js immediately
|
||||||
|
# rather than buffering first, so Node.js can send a keepalive response byte
|
||||||
|
# before the full body arrives (preventing upstream proxy "first-byte" timeouts).
|
||||||
|
# proxy_buffering off: forward that keepalive byte to Traefik/LB without delay.
|
||||||
|
location ~ ^/project/[^/]+/upload$ {
|
||||||
|
proxy_pass http://127.0.0.1:4000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 15m;
|
||||||
|
proxy_send_timeout 15m;
|
||||||
|
send_timeout 15m;
|
||||||
|
client_body_timeout 15m;
|
||||||
|
client_max_body_size 550m;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:4000;
|
proxy_pass http://127.0.0.1:4000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -150,6 +150,10 @@ app.post(
|
|||||||
FileUploadMiddleware.multerMiddleware,
|
FileUploadMiddleware.multerMiddleware,
|
||||||
ConversionController.convertPDFToJPEG
|
ConversionController.convertPDFToJPEG
|
||||||
)
|
)
|
||||||
|
app.get(
|
||||||
|
'/project/:project_id/user/:user_id/build/:build_id/thumbnail',
|
||||||
|
ConversionController.thumbnailFromBuild
|
||||||
|
)
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development' && global.__coverage__) {
|
if (process.env.NODE_ENV === 'development' && global.__coverage__) {
|
||||||
app.get('/coverage', (req, res) => {
|
app.get('/coverage', (req, res) => {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import os from 'node:os'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
import logger from '@overleaf/logger'
|
import logger from '@overleaf/logger'
|
||||||
import { expressify } from '@overleaf/promise-utils'
|
import { expressify } from '@overleaf/promise-utils'
|
||||||
import fs from 'node:fs/promises'
|
import fs from 'node:fs/promises'
|
||||||
@@ -16,10 +19,14 @@ import Settings from '@overleaf/settings'
|
|||||||
import Path from 'node:path'
|
import Path from 'node:path'
|
||||||
import { z } from '@overleaf/validation-tools'
|
import { z } from '@overleaf/validation-tools'
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
const CONVERSION_CONFIGS = {
|
const CONVERSION_CONFIGS = {
|
||||||
docx: { extension: 'docx' },
|
docx: { extension: 'docx' },
|
||||||
markdown: { extension: 'zip' },
|
markdown: { extension: 'zip' },
|
||||||
html: { extension: 'zip' },
|
html: { extension: 'zip' },
|
||||||
|
typst: { extension: 'typ' },
|
||||||
|
latex: { extension: 'tex' },
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertDocumentToLaTeX(req, res) {
|
async function convertDocumentToLaTeX(req, res) {
|
||||||
@@ -29,7 +36,7 @@ async function convertDocumentToLaTeX(req, res) {
|
|||||||
await fs.unlink(path).catch(() => {})
|
await fs.unlink(path).catch(() => {})
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
if (!conversionType || !['docx', 'markdown'].includes(conversionType)) {
|
if (!conversionType || !['docx', 'markdown', 'typst'].includes(conversionType)) {
|
||||||
await fs.unlink(path).catch(() => {})
|
await fs.unlink(path).catch(() => {})
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
@@ -251,8 +258,117 @@ async function convertProjectToDocument(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generates a JPEG thumbnail of page 1 of the compiled output using
|
||||||
|
// pdftocairo (poppler-utils). Tries output.pdf first (LaTeX / Quarto-PDF),
|
||||||
|
// then output-slides.pdf (Quarto RevealJS after a PDF-export compile), then
|
||||||
|
// falls back to rendering slide 1 of output.html via decktape (normal
|
||||||
|
// RevealJS preview compile). All temp dirs are cleaned up in finally.
|
||||||
|
async function thumbnailFromBuild(req, res) {
|
||||||
|
const { project_id: projectId, user_id: userId, build_id: buildId } = req.params
|
||||||
|
if (!buildId?.match(OutputCacheManager.BUILD_REGEX)) return res.sendStatus(400)
|
||||||
|
|
||||||
|
const compileName = userId ? `${projectId}-${userId}` : projectId
|
||||||
|
const buildDir = Path.join(
|
||||||
|
Settings.path.outputDir,
|
||||||
|
compileName,
|
||||||
|
OutputCacheManager.CACHE_SUBDIR,
|
||||||
|
buildId
|
||||||
|
)
|
||||||
|
|
||||||
|
let pdfPath = null
|
||||||
|
let deckTapeDir = null
|
||||||
|
|
||||||
|
for (const name of ['output.pdf', 'output-slides.pdf']) {
|
||||||
|
try {
|
||||||
|
const p = Path.join(buildDir, name)
|
||||||
|
await fs.access(p)
|
||||||
|
pdfPath = p
|
||||||
|
break
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pdfPath) {
|
||||||
|
const htmlPath = Path.join(buildDir, 'output.html')
|
||||||
|
try {
|
||||||
|
await fs.access(htmlPath)
|
||||||
|
deckTapeDir = await fs.mkdtemp(Path.join(os.tmpdir(), 'clsi-deck-'))
|
||||||
|
const chromeHome = Path.join(deckTapeDir, 'chrome')
|
||||||
|
await fs.mkdir(chromeHome, { recursive: true })
|
||||||
|
const slidePdf = Path.join(deckTapeDir, 'slide1.pdf')
|
||||||
|
await execFileAsync(
|
||||||
|
'decktape',
|
||||||
|
[
|
||||||
|
'--slides', '1',
|
||||||
|
'--chrome-arg=--no-sandbox',
|
||||||
|
'--chrome-arg=--disable-dev-shm-usage',
|
||||||
|
'--chrome-arg=--disable-gpu',
|
||||||
|
`--chrome-arg=--user-data-dir=${chromeHome}/data`,
|
||||||
|
htmlPath,
|
||||||
|
slidePdf,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
timeout: 60000,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
HOME: chromeHome,
|
||||||
|
XDG_CONFIG_HOME: chromeHome,
|
||||||
|
XDG_CACHE_HOME: chromeHome,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
pdfPath = slidePdf
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, projectId, buildId }, 'decktape slide1 thumbnail failed')
|
||||||
|
if (deckTapeDir) {
|
||||||
|
await fs.rm(deckTapeDir, { recursive: true, force: true }).catch(() => {})
|
||||||
|
deckTapeDir = null
|
||||||
|
}
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpDir = await fs.mkdtemp(Path.join(os.tmpdir(), 'clsi-thumb-'))
|
||||||
|
const outputBase = Path.join(tmpDir, 'thumb')
|
||||||
|
const jpegPath = outputBase + '.jpg'
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execFileAsync(
|
||||||
|
'pdftocairo',
|
||||||
|
[
|
||||||
|
'-jpeg',
|
||||||
|
'-jpegopt', 'quality=90',
|
||||||
|
'-singlefile',
|
||||||
|
'-scale-to-x', '794',
|
||||||
|
'-scale-to-y', '-1',
|
||||||
|
'-f', '1',
|
||||||
|
'-l', '1',
|
||||||
|
pdfPath,
|
||||||
|
outputBase,
|
||||||
|
],
|
||||||
|
{ timeout: 30000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
const jpegStat = await fs.stat(jpegPath)
|
||||||
|
res.setHeader('Content-Type', 'image/jpeg')
|
||||||
|
res.setHeader('Content-Length', jpegStat.size)
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||||
|
const readStream = fsSync.createReadStream(jpegPath)
|
||||||
|
await pipeline(readStream, res)
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, projectId, buildId }, 'thumbnail generation failed')
|
||||||
|
if (!res.headersSent) res.sendStatus(500)
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
|
||||||
|
if (deckTapeDir) {
|
||||||
|
await fs.rm(deckTapeDir, { recursive: true, force: true }).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
convertDocumentToLaTeX: expressify(convertDocumentToLaTeX),
|
convertDocumentToLaTeX: expressify(convertDocumentToLaTeX),
|
||||||
convertProjectToDocument: expressify(convertProjectToDocument),
|
convertProjectToDocument: expressify(convertProjectToDocument),
|
||||||
convertPDFToJPEG: expressify(convertPDFToJPEG),
|
convertPDFToJPEG: expressify(convertPDFToJPEG),
|
||||||
|
thumbnailFromBuild: expressify(thumbnailFromBuild),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ const CONVERSION_CONFIGS = {
|
|||||||
inputFilename: 'input.md',
|
inputFilename: 'input.md',
|
||||||
pandocArgs: ['--from', 'markdown'],
|
pandocArgs: ['--from', 'markdown'],
|
||||||
},
|
},
|
||||||
|
typst: {
|
||||||
|
inputFilename: 'input.typ',
|
||||||
|
pandocArgs: ['--from', 'typst'],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const PDF_TO_JPEG_CONFIGS = {
|
const PDF_TO_JPEG_CONFIGS = {
|
||||||
preview: { width: 794, quality: 90 },
|
preview: { width: 794, quality: 90 },
|
||||||
thumbnail: { width: 190, quality: 50 },
|
thumbnail: { width: 794, quality: 90 },
|
||||||
}
|
}
|
||||||
|
|
||||||
const PDF_TO_JPEG_INPUT_FILENAME = 'input.pdf'
|
const PDF_TO_JPEG_INPUT_FILENAME = 'input.pdf'
|
||||||
@@ -175,6 +179,31 @@ const LATEX_EXPORT_CONFIGS = {
|
|||||||
'--standalone',
|
'--standalone',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
typst: {
|
||||||
|
fileExtension: 'typ',
|
||||||
|
compressOutput: false,
|
||||||
|
getPandocArgs: ({ outputPath }) => [
|
||||||
|
'--output',
|
||||||
|
outputPath,
|
||||||
|
'--from',
|
||||||
|
'latex',
|
||||||
|
'--to',
|
||||||
|
'typst',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
latex: {
|
||||||
|
fileExtension: 'tex',
|
||||||
|
compressOutput: false,
|
||||||
|
getPandocArgs: ({ outputPath }) => [
|
||||||
|
'--output',
|
||||||
|
outputPath,
|
||||||
|
'--from',
|
||||||
|
'typst',
|
||||||
|
'--to',
|
||||||
|
'latex',
|
||||||
|
'--standalone',
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertLaTeXToDocumentInDirWithLock(
|
async function convertLaTeXToDocumentInDirWithLock(
|
||||||
|
|||||||
@@ -197,6 +197,10 @@ function _buildLatexCommand(mainFile, opts = {}) {
|
|||||||
command.push(...opts.flags)
|
command.push(...opts.flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Settings.clsi?.latexShellEscape) {
|
||||||
|
command.push('-shell-escape')
|
||||||
|
}
|
||||||
|
|
||||||
// TeX Engine selection. A .tex project may carry a non-LaTeX compiler value
|
// TeX Engine selection. A .tex project may carry a non-LaTeX compiler value
|
||||||
// (e.g. 'quarto', the fork-wide default for Project.compiler) because the
|
// (e.g. 'quarto', the fork-wide default for Project.compiler) because the
|
||||||
// runner is chosen by file extension, not by this setting. In that case fall
|
// runner is chosen by file extension, not by this setting. In that case fall
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { expressify, promisify } from '@overleaf/promise-utils'
|
|||||||
import { handleAuthenticateErrors } from './AuthenticationErrors.mjs'
|
import { handleAuthenticateErrors } from './AuthenticationErrors.mjs'
|
||||||
import EmailHelper from '../Helpers/EmailHelper.mjs'
|
import EmailHelper from '../Helpers/EmailHelper.mjs'
|
||||||
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
|
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
|
||||||
|
import Translations from '../../infrastructure/Translations.mjs'
|
||||||
|
|
||||||
const { hasAdminAccess } = AdminAuthorizationHelper
|
const { hasAdminAccess } = AdminAuthorizationHelper
|
||||||
|
|
||||||
@@ -221,6 +222,17 @@ const AuthenticationController = {
|
|||||||
|
|
||||||
await _afterLoginSessionSetupAsync(req, user)
|
await _afterLoginSessionSetupAsync(req, user)
|
||||||
|
|
||||||
|
// Sync user's stored language preference to cookie on login
|
||||||
|
if (user.languageCode) {
|
||||||
|
res.cookie(Translations.LANG_COOKIE_NAME, user.languageCode, {
|
||||||
|
maxAge: 365 * 24 * 60 * 60 * 1000,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: Settings.secureCookie,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
domain: Settings.cookieDomain,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
AuthenticationController._clearRedirectFromSession(req)
|
AuthenticationController._clearRedirectFromSession(req)
|
||||||
AnalyticsRegistrationSourceHelper.clearSource(req.session)
|
AnalyticsRegistrationSourceHelper.clearSource(req.session)
|
||||||
AnalyticsRegistrationSourceHelper.clearInbound(req.session)
|
AnalyticsRegistrationSourceHelper.clearInbound(req.session)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
fetchStreamWithResponse,
|
fetchStreamWithResponse,
|
||||||
RequestFailedError,
|
RequestFailedError,
|
||||||
} from '@overleaf/fetch-utils'
|
} from '@overleaf/fetch-utils'
|
||||||
|
import ThumbnailManager from './ThumbnailManager.mjs'
|
||||||
import Features from '../../infrastructure/Features.mjs'
|
import Features from '../../infrastructure/Features.mjs'
|
||||||
import ClsiCacheController from './ClsiCacheController.mjs'
|
import ClsiCacheController from './ClsiCacheController.mjs'
|
||||||
import { prepareZipAttachment } from '../../infrastructure/Response.mjs'
|
import { prepareZipAttachment } from '../../infrastructure/Response.mjs'
|
||||||
@@ -321,6 +322,10 @@ const _CompileController = {
|
|||||||
.catch(err =>
|
.catch(err =>
|
||||||
logger.warn({ err, projectId }, 'failed to update quartoFlavor')
|
logger.warn({ err, projectId }, 'failed to update quartoFlavor')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ThumbnailManager.generateAndCacheThumbnail(projectId, userId, buildId).catch(
|
||||||
|
err => logger.warn({ err, projectId }, 'thumbnail cache error')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -346,6 +351,15 @@ const _CompileController = {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getProjectThumbnail(req, res) {
|
||||||
|
const projectId = req.params.Project_id
|
||||||
|
const jpeg = await ThumbnailManager.getCachedThumbnail(projectId)
|
||||||
|
if (!jpeg) return res.sendStatus(404)
|
||||||
|
res.setHeader('Content-Type', 'image/jpeg')
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=60')
|
||||||
|
res.send(jpeg)
|
||||||
|
},
|
||||||
|
|
||||||
// Used for submissions through the public API
|
// Used for submissions through the public API
|
||||||
async compileSubmission(req, res) {
|
async compileSubmission(req, res) {
|
||||||
res.setTimeout(COMPILE_TIMEOUT_MS)
|
res.setTimeout(COMPILE_TIMEOUT_MS)
|
||||||
@@ -822,6 +836,7 @@ const CompileController = {
|
|||||||
proxySyncPdf: expressify(_CompileController.proxySyncPdf),
|
proxySyncPdf: expressify(_CompileController.proxySyncPdf),
|
||||||
proxySyncCode: expressify(_CompileController.proxySyncCode),
|
proxySyncCode: expressify(_CompileController.proxySyncCode),
|
||||||
wordCount: expressify(_CompileController.wordCount),
|
wordCount: expressify(_CompileController.wordCount),
|
||||||
|
getProjectThumbnail: expressify(_CompileController.getProjectThumbnail),
|
||||||
|
|
||||||
_getSafeProjectName: _CompileController._getSafeProjectName,
|
_getSafeProjectName: _CompileController._getSafeProjectName,
|
||||||
_getSplitTestOptions,
|
_getSplitTestOptions,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import RedisWrapper from '../../infrastructure/RedisWrapper.mjs'
|
||||||
|
import { fetchStreamWithResponse, RequestFailedError } from '@overleaf/fetch-utils'
|
||||||
|
|
||||||
|
const rclient = RedisWrapper.client('web')
|
||||||
|
const THUMBNAIL_TTL = 60 * 60 * 24 * 90 // 90 days
|
||||||
|
|
||||||
|
function redisKey(projectId) {
|
||||||
|
return `Thumbnail:${projectId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function clsiThumbnailUrl(projectId, userId, buildId) {
|
||||||
|
return `${Settings.apis.clsi.url}/project/${projectId}/user/${userId}/build/${buildId}/thumbnail`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateAndCacheThumbnail(projectId, userId, buildId) {
|
||||||
|
if (!userId || !buildId) return
|
||||||
|
|
||||||
|
const url = clsiThumbnailUrl(projectId, userId, buildId)
|
||||||
|
let stream
|
||||||
|
try {
|
||||||
|
;({ stream } = await fetchStreamWithResponse(url))
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof RequestFailedError)) {
|
||||||
|
logger.warn(
|
||||||
|
{ err, projectId, buildId },
|
||||||
|
'unexpected error fetching thumbnail from CLSI'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chunks = []
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
const jpegBuffer = Buffer.concat(chunks)
|
||||||
|
if (jpegBuffer.length === 0) return
|
||||||
|
await rclient.set(
|
||||||
|
redisKey(projectId),
|
||||||
|
jpegBuffer.toString('base64'),
|
||||||
|
'EX',
|
||||||
|
THUMBNAIL_TTL
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, projectId }, 'failed to cache thumbnail in Redis')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCachedThumbnail(projectId) {
|
||||||
|
try {
|
||||||
|
const b64 = await rclient.get(redisKey(projectId))
|
||||||
|
if (!b64) return null
|
||||||
|
return Buffer.from(b64, 'base64')
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, projectId }, 'failed to get thumbnail from Redis')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { generateAndCacheThumbnail, getCachedThumbnail }
|
||||||
@@ -13,6 +13,15 @@ import { expressify } from '@overleaf/promise-utils'
|
|||||||
import { pipeline } from 'node:stream/promises'
|
import { pipeline } from 'node:stream/promises'
|
||||||
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
|
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
|
||||||
import { DocumentConversionError } from '../Errors/Errors.js'
|
import { DocumentConversionError } from '../Errors/Errors.js'
|
||||||
|
import ProjectLocator from '../Project/ProjectLocator.mjs'
|
||||||
|
import ProjectEntityUpdateHandler from '../Project/ProjectEntityUpdateHandler.mjs'
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
|
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import nodePath from 'node:path'
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
const { z, zz, parseReq } = Validation
|
const { z, zz, parseReq } = Validation
|
||||||
|
|
||||||
@@ -20,6 +29,8 @@ const SUPPORTED_CONVERSION_TYPES = new Map([
|
|||||||
['docx', 'docx'],
|
['docx', 'docx'],
|
||||||
['markdown', 'zip'],
|
['markdown', 'zip'],
|
||||||
['html', 'zip'],
|
['html', 'zip'],
|
||||||
|
['typst', 'typ'],
|
||||||
|
['latex', 'tex'],
|
||||||
])
|
])
|
||||||
|
|
||||||
const exportProjectConversionSchema = z.object({
|
const exportProjectConversionSchema = z.object({
|
||||||
@@ -99,14 +110,14 @@ async function exportProjectConversion(req, res) {
|
|||||||
{ compileFromHistory, rootResourcePath }
|
{ compileFromHistory, rootResourcePath }
|
||||||
)
|
)
|
||||||
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', {
|
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', {
|
||||||
sourceFormat: 'latex',
|
sourceFormat: type === 'latex' ? 'typst' : 'latex',
|
||||||
targetFormat: type,
|
targetFormat: type,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
operation: 'export',
|
operation: 'export',
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', {
|
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', {
|
||||||
sourceFormat: 'latex',
|
sourceFormat: type === 'latex' ? 'typst' : 'latex',
|
||||||
targetFormat: type,
|
targetFormat: type,
|
||||||
status: 'failure',
|
status: 'failure',
|
||||||
operation: 'export',
|
operation: 'export',
|
||||||
@@ -164,9 +175,102 @@ async function downloadPreparedProjectExport(req, res) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DOC_CONVERSION_CONFIGS = {
|
||||||
|
typst: { fromExt: 'tex', toExt: 'typ', pandocFrom: 'latex', pandocTo: 'typst' },
|
||||||
|
latex: { fromExt: 'typ', toExt: 'tex', pandocFrom: 'typst', pandocTo: 'latex' },
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertDocInProject(req, res) {
|
||||||
|
const { Project_id: projectId, Doc_id: docId, type } = req.params
|
||||||
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
|
||||||
|
const config = DOC_CONVERSION_CONFIGS[type]
|
||||||
|
if (!config) return res.status(400).json({ error: 'unsupported conversion type' })
|
||||||
|
|
||||||
|
const { lines } = await DocumentUpdaterHandler.promises.getDocument(
|
||||||
|
projectId,
|
||||||
|
docId,
|
||||||
|
-1
|
||||||
|
)
|
||||||
|
const content = lines.join('\n')
|
||||||
|
|
||||||
|
const { element, folder } = await ProjectLocator.promises.findElement({
|
||||||
|
project_id: projectId,
|
||||||
|
element_id: docId,
|
||||||
|
type: 'doc',
|
||||||
|
})
|
||||||
|
const parentFolderId = folder._id
|
||||||
|
|
||||||
|
const baseName = element.name.endsWith('.' + config.fromExt)
|
||||||
|
? element.name.slice(0, -(config.fromExt.length + 1))
|
||||||
|
: element.name
|
||||||
|
const outputName = baseName + '.' + config.toExt
|
||||||
|
|
||||||
|
const tmpDir = await mkdtemp(nodePath.join(tmpdir(), 'verso-convert-'))
|
||||||
|
let convertedContent
|
||||||
|
try {
|
||||||
|
const inputPath = nodePath.join(tmpDir, `input.${config.fromExt}`)
|
||||||
|
const outputPath = nodePath.join(tmpDir, `output.${config.toExt}`)
|
||||||
|
await writeFile(inputPath, content, 'utf8')
|
||||||
|
try {
|
||||||
|
await execFileAsync(
|
||||||
|
'pandoc',
|
||||||
|
[
|
||||||
|
inputPath,
|
||||||
|
'--from', config.pandocFrom,
|
||||||
|
'--to', config.pandocTo,
|
||||||
|
'--output', outputPath,
|
||||||
|
'--standalone',
|
||||||
|
],
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(422).json({ error: err.stderr || err.message })
|
||||||
|
}
|
||||||
|
convertedContent = await readFile(outputPath, 'utf8')
|
||||||
|
} finally {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingDoc = folder.docs?.find(d => d.name === outputName)
|
||||||
|
let resultDocId, resultName
|
||||||
|
if (existingDoc) {
|
||||||
|
await DocumentUpdaterHandler.promises.setDocument(
|
||||||
|
projectId,
|
||||||
|
existingDoc._id.toString(),
|
||||||
|
userId,
|
||||||
|
convertedContent.split('\n'),
|
||||||
|
'convert'
|
||||||
|
)
|
||||||
|
resultDocId = existingDoc._id
|
||||||
|
resultName = existingDoc.name
|
||||||
|
} else {
|
||||||
|
const { doc } = await ProjectEntityUpdateHandler.promises.addDoc(
|
||||||
|
projectId,
|
||||||
|
parentFolderId,
|
||||||
|
outputName,
|
||||||
|
convertedContent.split('\n'),
|
||||||
|
userId,
|
||||||
|
'convert'
|
||||||
|
)
|
||||||
|
resultDocId = doc._id
|
||||||
|
resultName = doc.name
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectAuditLogHandler.addEntryInBackground(
|
||||||
|
projectId,
|
||||||
|
'doc-converted',
|
||||||
|
userId,
|
||||||
|
req.ip
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({ docId: resultDocId, name: resultName, parentFolderId: parentFolderId.toString(), isNew: !existingDoc })
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
exportProjectConversion: expressify(exportProjectConversion),
|
exportProjectConversion: expressify(exportProjectConversion),
|
||||||
downloadPreparedProjectExport: expressify(downloadPreparedProjectExport),
|
downloadPreparedProjectExport: expressify(downloadPreparedProjectExport),
|
||||||
|
convertDocInProject: expressify(convertDocInProject),
|
||||||
|
|
||||||
downloadProject(req, res, next) {
|
downloadProject(req, res, next) {
|
||||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default _.template(`\
|
|||||||
<tr style="padding: 0; text-align: left; vertical-align: top;">
|
<tr style="padding: 0; text-align: left; vertical-align: top;">
|
||||||
<th style="margin: 0; padding: 0; text-align: left;">
|
<th style="margin: 0; padding: 0; text-align: left;">
|
||||||
<% if (title) { %>
|
<% if (title) { %>
|
||||||
<h3 class="force-overleaf-style" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
|
<h3 class="force-verso-style" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
|
||||||
<%= title %>
|
<%= title %>
|
||||||
</h3>
|
</h3>
|
||||||
<% } %>
|
<% } %>
|
||||||
@@ -25,7 +25,7 @@ export default _.template(`\
|
|||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<% (message).forEach(function(paragraph) { %>
|
<% (message).forEach(function(paragraph) { %>
|
||||||
<p class="force-overleaf-style" style="margin: 0 0 10px 0; padding: 0;">
|
<p class="force-verso-style" style="margin: 0 0 10px 0; padding: 0;">
|
||||||
<%= paragraph %>
|
<%= paragraph %>
|
||||||
</p>
|
</p>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default _.template(`\
|
|||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<% (message).forEach(function(paragraph) { %>
|
<% (message).forEach(function(paragraph) { %>
|
||||||
<p class="force-overleaf-style" style="margin: 0 0 16px 0;">
|
<p class="force-verso-style" style="margin: 0 0 16px 0;">
|
||||||
<%= paragraph %>
|
<%= paragraph %>
|
||||||
</p>
|
</p>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default _.template(`\
|
|||||||
<tr style="padding: 0; text-align: left; vertical-align: top;">
|
<tr style="padding: 0; text-align: left; vertical-align: top;">
|
||||||
<th style="margin: 0; padding: 0; text-align: left;">
|
<th style="margin: 0; padding: 0; text-align: left;">
|
||||||
<% if (title) { %>
|
<% if (title) { %>
|
||||||
<h3 class="force-overleaf-style" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
|
<h3 class="force-verso-style" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
|
||||||
<%= title %>
|
<%= title %>
|
||||||
</h3>
|
</h3>
|
||||||
<% } %>
|
<% } %>
|
||||||
@@ -25,7 +25,7 @@ export default _.template(`\
|
|||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<% (message).forEach(function(paragraph) { %>
|
<% (message).forEach(function(paragraph) { %>
|
||||||
<p class="force-overleaf-style" style="margin: 0 0 10px 0; padding: 0;">
|
<p class="force-verso-style" style="margin: 0 0 10px 0; padding: 0;">
|
||||||
<%= paragraph %>
|
<%= paragraph %>
|
||||||
</p>
|
</p>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
@@ -52,7 +52,7 @@ export default _.template(`\
|
|||||||
<p style="margin: 0; padding: 0;"> </p>
|
<p style="margin: 0; padding: 0;"> </p>
|
||||||
|
|
||||||
<% (secondaryMessage).forEach(function(paragraph) { %>
|
<% (secondaryMessage).forEach(function(paragraph) { %>
|
||||||
<p class="force-overleaf-style">
|
<p class="force-verso-style">
|
||||||
<%= paragraph %>
|
<%= paragraph %>
|
||||||
</p>
|
</p>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
@@ -60,11 +60,11 @@ export default _.template(`\
|
|||||||
|
|
||||||
<p style="margin: 0; padding: 0;"> </p>
|
<p style="margin: 0; padding: 0;"> </p>
|
||||||
|
|
||||||
<p class="force-overleaf-style" style="font-size: 12px;">
|
<p class="force-verso-style" style="font-size: 12px;">
|
||||||
If the button above does not appear, please copy and paste this link into your browser's address bar:
|
If the button above does not appear, please copy and paste this link into your browser's address bar:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="force-overleaf-style" style="font-size: 12px;">
|
<p class="force-verso-style" style="font-size: 12px;">
|
||||||
<%= ctaURL %>
|
<%= ctaURL %>
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default _.template(`\
|
|||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<% (message).forEach(function(paragraph) { %>
|
<% (message).forEach(function(paragraph) { %>
|
||||||
<p class="force-overleaf-style" style="margin: 0 0 16px 0;">
|
<p class="force-verso-style" style="margin: 0 0 16px 0;">
|
||||||
<%= paragraph %>
|
<%= paragraph %>
|
||||||
</p>
|
</p>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
@@ -32,7 +32,7 @@ export default _.template(`\
|
|||||||
|
|
||||||
<% if (secondaryMessage && secondaryMessage.length > 0) { %>
|
<% if (secondaryMessage && secondaryMessage.length > 0) { %>
|
||||||
<% (secondaryMessage).forEach(function(paragraph) { %>
|
<% (secondaryMessage).forEach(function(paragraph) { %>
|
||||||
<p class="force-overleaf-style" style="margin: 0 0 16px 0;">
|
<p class="force-verso-style" style="margin: 0 0 16px 0;">
|
||||||
<%= paragraph %>
|
<%= paragraph %>
|
||||||
</p>
|
</p>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ templates.confirmCode = NoCTAEmailTemplate({
|
|||||||
return ''
|
return ''
|
||||||
},
|
},
|
||||||
subject(opts) {
|
subject(opts) {
|
||||||
return `Confirm your email address on Overleaf (${opts.confirmCode})`
|
return `Confirm your email address on ${settings.appName} (${opts.confirmCode})`
|
||||||
},
|
},
|
||||||
title(opts) {
|
title(opts) {
|
||||||
return 'Confirm your email address'
|
return 'Confirm your email address'
|
||||||
@@ -284,7 +284,7 @@ templates.confirmCode = NoCTAEmailTemplate({
|
|||||||
message(opts, isPlainText) {
|
message(opts, isPlainText) {
|
||||||
const msg = opts.welcomeUser
|
const msg = opts.welcomeUser
|
||||||
? [
|
? [
|
||||||
`Welcome to Overleaf! We're so glad you joined us.`,
|
`Welcome to ${settings.appName}! We're so glad you joined us.`,
|
||||||
'Use this 6-digit confirmation code to finish your setup.',
|
'Use this 6-digit confirmation code to finish your setup.',
|
||||||
]
|
]
|
||||||
: ['Use this 6-digit code to confirm your email address.']
|
: ['Use this 6-digit code to confirm your email address.']
|
||||||
@@ -477,7 +477,7 @@ templates.inviteNewUserToJoinManagedUsers = ctaTemplate({
|
|||||||
templates.groupSSOLinkingInvite = ctaTemplate({
|
templates.groupSSOLinkingInvite = ctaTemplate({
|
||||||
subject(opts) {
|
subject(opts) {
|
||||||
const subjectPrefix = opts.reminder ? 'Reminder: ' : 'Action required: '
|
const subjectPrefix = opts.reminder ? 'Reminder: ' : 'Action required: '
|
||||||
return `${subjectPrefix}Authenticate your Overleaf account`
|
return `${subjectPrefix}Authenticate your ${settings.appName} account`
|
||||||
},
|
},
|
||||||
title(opts) {
|
title(opts) {
|
||||||
const titlePrefix = opts.reminder ? 'Reminder: ' : ''
|
const titlePrefix = opts.reminder ? 'Reminder: ' : ''
|
||||||
@@ -495,8 +495,8 @@ templates.groupSSOLinkingInvite = ctaTemplate({
|
|||||||
</div>
|
</div>
|
||||||
</br>
|
</br>
|
||||||
<div>
|
<div>
|
||||||
You won't need to remember a separate email address and password to sign in to Overleaf.
|
You won't need to remember a separate email address and password to sign in to ${settings.appName}.
|
||||||
All you need to do is authenticate your existing Overleaf account with your SSO provider.
|
All you need to do is authenticate your existing ${settings.appName} account with your SSO provider.
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
]
|
]
|
||||||
@@ -517,7 +517,7 @@ templates.groupSSOLinkingInvite = ctaTemplate({
|
|||||||
|
|
||||||
templates.groupSSOReauthenticate = ctaTemplate({
|
templates.groupSSOReauthenticate = ctaTemplate({
|
||||||
subject(opts) {
|
subject(opts) {
|
||||||
return 'Action required: Reauthenticate your Overleaf account'
|
return `Action required: Reauthenticate your ${settings.appName} account`
|
||||||
},
|
},
|
||||||
title(opts) {
|
title(opts) {
|
||||||
return 'Action required: Reauthenticate SSO'
|
return 'Action required: Reauthenticate SSO'
|
||||||
@@ -526,8 +526,8 @@ templates.groupSSOReauthenticate = ctaTemplate({
|
|||||||
return [
|
return [
|
||||||
`Hi,
|
`Hi,
|
||||||
<div>
|
<div>
|
||||||
Single sign-on for your Overleaf group has been updated.
|
Single sign-on for your ${settings.appName} group has been updated.
|
||||||
This means you need to reauthenticate your Overleaf account with your group’s SSO provider.
|
This means you need to reauthenticate your ${settings.appName} account with your group’s SSO provider.
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
]
|
]
|
||||||
@@ -538,7 +538,7 @@ templates.groupSSOReauthenticate = ctaTemplate({
|
|||||||
} else {
|
} else {
|
||||||
const passwordResetUrl = `${settings.siteUrl}/user/password/reset`
|
const passwordResetUrl = `${settings.siteUrl}/user/password/reset`
|
||||||
return [
|
return [
|
||||||
`If you’re not currently logged in to Overleaf, you'll need to <a href="${passwordResetUrl}">set a new password</a> to reauthenticate.`,
|
`If you’re not currently logged in to ${settings.appName}, you’ll need to <a href="${passwordResetUrl}">set a new password</a> to reauthenticate.`,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -556,9 +556,9 @@ templates.groupSSOReauthenticate = ctaTemplate({
|
|||||||
templates.groupSSODisabled = ctaTemplate({
|
templates.groupSSODisabled = ctaTemplate({
|
||||||
subject(opts) {
|
subject(opts) {
|
||||||
if (opts.userIsManaged) {
|
if (opts.userIsManaged) {
|
||||||
return `Action required: Set your Overleaf password`
|
return `Action required: Set your ${settings.appName} password`
|
||||||
} else {
|
} else {
|
||||||
return 'A change to your Overleaf login options'
|
return `A change to your ${settings.appName} login options`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
title(opts) {
|
title(opts) {
|
||||||
@@ -567,12 +567,12 @@ templates.groupSSODisabled = ctaTemplate({
|
|||||||
message(opts, isPlainText) {
|
message(opts, isPlainText) {
|
||||||
const loginUrl = `${settings.siteUrl}/login`
|
const loginUrl = `${settings.siteUrl}/login`
|
||||||
let whatDoesThisMeanExplanation = [
|
let whatDoesThisMeanExplanation = [
|
||||||
`You can still log in to Overleaf using one of our other <a href="${loginUrl}" style="color: #0F7A06; text-decoration: none;">login options</a> or with your email address and password.`,
|
`You can still log in to ${settings.appName} using one of our other <a href="${loginUrl}" style="color: #1d7a6e; text-decoration: none;">login options</a> or with your email address and password.`,
|
||||||
`If you don't have a password, you can set one now.`,
|
`If you don't have a password, you can set one now.`,
|
||||||
]
|
]
|
||||||
if (opts.userIsManaged) {
|
if (opts.userIsManaged) {
|
||||||
whatDoesThisMeanExplanation = [
|
whatDoesThisMeanExplanation = [
|
||||||
'You now need an email address and password to sign in to your Overleaf account.',
|
`You now need an email address and password to sign in to your ${settings.appName} account.`,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,7 +626,7 @@ templates.surrenderAccountForManagedUsers = ctaTemplate({
|
|||||||
|
|
||||||
const managedUsersLink = EmailMessageHelper.displayLink(
|
const managedUsersLink = EmailMessageHelper.displayLink(
|
||||||
'user account management',
|
'user account management',
|
||||||
`${settings.siteUrl}/learn/how-to/Understanding_Managed_Overleaf_Accounts`,
|
`${settings.siteUrl}/learn/how-to/Understanding_Managed_Accounts`,
|
||||||
isPlainText
|
isPlainText
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -757,22 +757,17 @@ templates.userOnboardingEmail = NoCTAEmailTemplate({
|
|||||||
message(opts, isPlainText) {
|
message(opts, isPlainText) {
|
||||||
const learnLatexLink = EmailMessageHelper.displayLink(
|
const learnLatexLink = EmailMessageHelper.displayLink(
|
||||||
'Learn LaTeX in 30 minutes',
|
'Learn LaTeX in 30 minutes',
|
||||||
`${settings.siteUrl}/learn/latex/Learn_LaTeX_in_30_minutes?utm_source=overleaf&utm_medium=email&utm_campaign=onboarding`,
|
`${settings.siteUrl}/learn/latex/Learn_LaTeX_in_30_minutes?utm_source=verso&utm_medium=email&utm_campaign=onboarding`,
|
||||||
isPlainText
|
isPlainText
|
||||||
)
|
)
|
||||||
const templatesLinks = EmailMessageHelper.displayLink(
|
const templatesLinks = EmailMessageHelper.displayLink(
|
||||||
'Find a beautiful template',
|
'Find a beautiful template',
|
||||||
`${settings.siteUrl}/latex/templates?utm_source=overleaf&utm_medium=email&utm_campaign=onboarding`,
|
`${settings.siteUrl}/latex/templates?utm_source=verso&utm_medium=email&utm_campaign=onboarding`,
|
||||||
isPlainText
|
isPlainText
|
||||||
)
|
)
|
||||||
const collaboratorsLink = EmailMessageHelper.displayLink(
|
const collaboratorsLink = EmailMessageHelper.displayLink(
|
||||||
'Work with your collaborators',
|
'Work with your collaborators',
|
||||||
`${settings.siteUrl}/learn/how-to/Sharing_a_project?utm_source=overleaf&utm_medium=email&utm_campaign=onboarding`,
|
`${settings.siteUrl}/learn/how-to/Sharing_a_project?utm_source=verso&utm_medium=email&utm_campaign=onboarding`,
|
||||||
isPlainText
|
|
||||||
)
|
|
||||||
const siteLink = EmailMessageHelper.displayLink(
|
|
||||||
'www.overleaf.com',
|
|
||||||
settings.siteUrl,
|
|
||||||
isPlainText
|
isPlainText
|
||||||
)
|
)
|
||||||
const userSettingsLink = EmailMessageHelper.displayLink(
|
const userSettingsLink = EmailMessageHelper.displayLink(
|
||||||
@@ -780,28 +775,21 @@ templates.userOnboardingEmail = NoCTAEmailTemplate({
|
|||||||
`${settings.siteUrl}/user/email-preferences`,
|
`${settings.siteUrl}/user/email-preferences`,
|
||||||
isPlainText
|
isPlainText
|
||||||
)
|
)
|
||||||
const onboardingSurveyLink = EmailMessageHelper.displayLink(
|
|
||||||
'Join our user feedback program',
|
|
||||||
'https://forms.gle/DB7pdk2B1VFQqVVB9',
|
|
||||||
isPlainText
|
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
`Thanks for signing up for ${settings.appName} recently. We hope you've been finding it useful! Here are some key features to help you get the most out of the service:`,
|
`Thanks for signing up for ${settings.appName} recently. We hope you've been finding it useful! Here are some key features to help you get the most out of the service:`,
|
||||||
`${learnLatexLink}: In this tutorial we provide a quick and easy first introduction to LaTeX with no prior knowledge required. By the time you are finished, you will have written your first LaTeX document!`,
|
`${learnLatexLink}: In this tutorial we provide a quick and easy first introduction to LaTeX with no prior knowledge required. By the time you are finished, you will have written your first LaTeX document!`,
|
||||||
`${templatesLinks}: If you're looking for a template or example to get started, we've a large selection available in our template gallery, including CVs, project reports, journal articles and more.`,
|
`${templatesLinks}: If you're looking for a template or example to get started, we've a large selection available in our template gallery, including CVs, project reports, journal articles and more.`,
|
||||||
`${collaboratorsLink}: One of the key features of Overleaf is the ability to share projects and collaborate on them with other users. Find out how to share your projects with your colleagues in this quick how-to guide.`,
|
`${collaboratorsLink}: One of the key features of ${settings.appName} is the ability to share projects and collaborate on them with other users. Find out how to share your projects with your colleagues in this quick how-to guide.`,
|
||||||
`${onboardingSurveyLink} to help us make Overleaf even better!`,
|
`Thanks for using ${settings.appName}!`,
|
||||||
'Thanks again for using Overleaf :)',
|
`The ${settings.appName} Team<hr>`,
|
||||||
`Lee`,
|
`You're receiving this email because you've recently signed up for a ${settings.appName} account. If you've previously subscribed to emails about product offers and company news and events, you can unsubscribe ${userSettingsLink}.`,
|
||||||
`Lee Shalit<br />CEO<br />${siteLink}<hr>`,
|
|
||||||
`You're receiving this email because you've recently signed up for an Overleaf account. If you've previously subscribed to emails about product offers and company news and events, you can unsubscribe ${userSettingsLink}.`,
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
templates.securityAlert = NoCTAEmailTemplate({
|
templates.securityAlert = NoCTAEmailTemplate({
|
||||||
subject(opts) {
|
subject(opts) {
|
||||||
return `Overleaf security note: ${opts.action}`
|
return `${settings.appName} security note: ${opts.action}`
|
||||||
},
|
},
|
||||||
title(opts) {
|
title(opts) {
|
||||||
return opts.action.charAt(0).toUpperCase() + opts.action.slice(1)
|
return opts.action.charAt(0).toUpperCase() + opts.action.slice(1)
|
||||||
@@ -837,12 +825,11 @@ templates.securityAlert = NoCTAEmailTemplate({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const GIT_TOKEN_DOCS_URL =
|
const GIT_TOKEN_DOCS_URL = `${settings.siteUrl}/learn/how-to/Git_integration`
|
||||||
'https://docs.overleaf.com/integrations-and-add-ons/git-integration-and-github-synchronization/git-integration/git-integration-authentication-tokens#how-to-generate-authentication-tokens'
|
|
||||||
|
|
||||||
templates.gitTokenExpiringSoon = NoCTAEmailTemplate({
|
templates.gitTokenExpiringSoon = NoCTAEmailTemplate({
|
||||||
subject() {
|
subject() {
|
||||||
return 'Your Overleaf token is about to expire'
|
return `Your ${settings.appName} token is about to expire`
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return 'Your token is about to expire'
|
return 'Your token is about to expire'
|
||||||
@@ -866,14 +853,14 @@ templates.gitTokenExpiringSoon = NoCTAEmailTemplate({
|
|||||||
`If you haven't already, you'll need to generate a new token in your ${settingsLink}.`,
|
`If you haven't already, you'll need to generate a new token in your ${settingsLink}.`,
|
||||||
`Take a look at ${docsLink} if you need more help.`,
|
`Take a look at ${docsLink} if you need more help.`,
|
||||||
'All the best,',
|
'All the best,',
|
||||||
'Team Overleaf',
|
`The ${settings.appName} Team`,
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
templates.gitTokenExpired = NoCTAEmailTemplate({
|
templates.gitTokenExpired = NoCTAEmailTemplate({
|
||||||
subject() {
|
subject() {
|
||||||
return 'Your Overleaf token has expired'
|
return `Your ${settings.appName} token has expired`
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return 'Token expired'
|
return 'Token expired'
|
||||||
@@ -897,7 +884,7 @@ templates.gitTokenExpired = NoCTAEmailTemplate({
|
|||||||
`If you haven't already, you'll need to generate a new token in your ${settingsLink}.`,
|
`If you haven't already, you'll need to generate a new token in your ${settingsLink}.`,
|
||||||
`Take a look at ${docsLink} if you need more help.`,
|
`Take a look at ${docsLink} if you need more help.`,
|
||||||
'All the best,',
|
'All the best,',
|
||||||
'Team Overleaf',
|
`The ${settings.appName} Team`,
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1040,7 +1027,7 @@ templates.removeGroupMember = NoCTAEmailTemplate({
|
|||||||
|
|
||||||
templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
|
templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
|
||||||
subject(opts) {
|
subject(opts) {
|
||||||
return `Action required: Tax exemption verification for Overleaf [${opts.ein}]`
|
return `Action required: Tax exemption verification for ${settings.appName} [${opts.ein}]`
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return 'Action required: Tax exemption verification'
|
return 'Action required: Tax exemption verification'
|
||||||
@@ -1060,7 +1047,7 @@ templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
|
|||||||
'If you have any questions, let us know by replying to this email.',
|
'If you have any questions, let us know by replying to this email.',
|
||||||
'<br/>',
|
'<br/>',
|
||||||
'Best wishes,',
|
'Best wishes,',
|
||||||
'Team Overleaf',
|
`The ${settings.appName} Team`,
|
||||||
'<br/>',
|
'<br/>',
|
||||||
`Our reference: ${opts.stripeCustomerId}`,
|
`Our reference: ${opts.stripeCustomerId}`,
|
||||||
]
|
]
|
||||||
@@ -1069,17 +1056,17 @@ templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
|
|||||||
|
|
||||||
templates.groupMemberLimitWarning = ctaTemplate({
|
templates.groupMemberLimitWarning = ctaTemplate({
|
||||||
subject(opts) {
|
subject(opts) {
|
||||||
return `Action needed: Your Overleaf group is nearly out of licenses`
|
return `Action needed: Your ${settings.appName} group is nearly out of licenses`
|
||||||
},
|
},
|
||||||
title(opts) {
|
title(opts) {
|
||||||
return `Action needed: Your Overleaf group is nearly out of licenses`
|
return `Action needed: Your ${settings.appName} group is nearly out of licenses`
|
||||||
},
|
},
|
||||||
greeting(opts) {
|
greeting(opts) {
|
||||||
return opts.firstName ? `Hi ${opts.firstName},` : 'Hi there,'
|
return opts.firstName ? `Hi ${opts.firstName},` : 'Hi there,'
|
||||||
},
|
},
|
||||||
message(opts) {
|
message(opts) {
|
||||||
return [
|
return [
|
||||||
`Your Overleaf group <b>${opts.groupName}</b> is close to its license limit.`,
|
`Your ${settings.appName} group <b>${opts.groupName}</b> is close to its license limit.`,
|
||||||
`<b>${opts.currentMembers} of ${opts.membersLimit} licenses are in use (${opts.remainingSeats} remaining).</b>`,
|
`<b>${opts.currentMembers} of ${opts.membersLimit} licenses are in use (${opts.remainingSeats} remaining).</b>`,
|
||||||
'Because domain capture is enabled, users from your domain can join automatically via SSO.' +
|
'Because domain capture is enabled, users from your domain can join automatically via SSO.' +
|
||||||
'<br/>' +
|
'<br/>' +
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ export default _.template(`\
|
|||||||
.email-layout-table { border-collapse: collapse !important; }
|
.email-layout-table { border-collapse: collapse !important; }
|
||||||
a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; }
|
a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; }
|
||||||
|
|
||||||
.force-overleaf-style a,
|
.force-verso-style a,
|
||||||
.force-overleaf-style a[href] {
|
.force-verso-style a[href] {
|
||||||
color: ${colors.linkGreen} !important;
|
color: ${colors.linkGreen} !important;
|
||||||
text-decoration: underline !important;
|
text-decoration: underline !important;
|
||||||
-moz-hyphens: none;
|
-moz-hyphens: none;
|
||||||
@@ -69,10 +69,10 @@ export default _.template(`\
|
|||||||
-webkit-hyphens: none;
|
-webkit-hyphens: none;
|
||||||
hyphens: none;
|
hyphens: none;
|
||||||
}
|
}
|
||||||
.force-overleaf-style a:visited,
|
.force-verso-style a:visited,
|
||||||
.force-overleaf-style a[href]:visited { color: ${colors.linkGreen}; }
|
.force-verso-style a[href]:visited { color: ${colors.linkGreen}; }
|
||||||
.force-overleaf-style a:hover,
|
.force-verso-style a:hover,
|
||||||
.force-overleaf-style a[href]:hover { color: ${colors.linkHover}; }
|
.force-verso-style a[href]:hover { color: ${colors.linkHover}; }
|
||||||
|
|
||||||
@media only screen and (min-width: 621px) {
|
@media only screen and (min-width: 621px) {
|
||||||
.email-card-inner { padding: 56px !important; }
|
.email-card-inner { padding: 56px !important; }
|
||||||
@@ -149,7 +149,7 @@ export default _.template(`\
|
|||||||
|
|
||||||
<% if (footerMessage) { %>
|
<% if (footerMessage) { %>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" class="force-overleaf-style" style="padding: 0 0 32px 0; font-family: ${fontFamily}; font-size: 14px; color: ${colors.textMuted}; line-height: 20px;">
|
<td align="center" class="force-verso-style" style="padding: 0 0 32px 0; font-family: ${fontFamily}; font-size: 14px; color: ${colors.textMuted}; line-height: 20px;">
|
||||||
<%= footerMessage %>
|
<%= footerMessage %>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ export const colors = {
|
|||||||
textDark: '#1b222c',
|
textDark: '#1b222c',
|
||||||
textMuted: '#495365',
|
textMuted: '#495365',
|
||||||
background: '#f4f5f6',
|
background: '#f4f5f6',
|
||||||
ctaGreen: '#098842',
|
ctaGreen: '#2a9d8f',
|
||||||
ctaText: '#ffffff',
|
ctaText: '#ffffff',
|
||||||
linkGreen: '#1e6b41',
|
linkGreen: '#1d7a6e',
|
||||||
linkHover: '#155a30',
|
linkHover: '#155e55',
|
||||||
logoGreen: '#04652f',
|
logoGreen: '#2a9d8f',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import GitSyncHandler from './GitSyncHandler.mjs'
|
||||||
|
import SessionManager from '../Authentication/SessionManager.mjs'
|
||||||
|
import { expressify } from '@overleaf/promise-utils'
|
||||||
|
|
||||||
|
async function configureGitSync(req, res) {
|
||||||
|
const projectId = req.params.project_id
|
||||||
|
const {
|
||||||
|
remoteUrl,
|
||||||
|
subPath = '',
|
||||||
|
pdfPath = '',
|
||||||
|
pushFiles = true,
|
||||||
|
pushPdf = true,
|
||||||
|
branch = 'main',
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
if (typeof remoteUrl !== 'string' || remoteUrl.trim() === '') {
|
||||||
|
return res.status(400).json({ error: 'remoteUrl is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedUrl = remoteUrl.trim()
|
||||||
|
if (!trimmedUrl.startsWith('https://') && !trimmedUrl.startsWith('git@')) {
|
||||||
|
return res.status(400).json({ error: 'remoteUrl must start with https:// or git@' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedSubPath = String(subPath).trim().replace(/^\/+|\/+$/g, '')
|
||||||
|
if (trimmedSubPath.includes('..')) {
|
||||||
|
return res.status(400).json({ error: 'subPath must not contain ..' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedPdfPath = String(pdfPath).trim().replace(/^\/+/, '')
|
||||||
|
if (trimmedPdfPath.includes('..')) {
|
||||||
|
return res.status(400).json({ error: 'pdfPath must not contain ..' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedBranch = String(branch).trim()
|
||||||
|
if (!/^[a-zA-Z0-9/_.-]+$/.test(trimmedBranch) || trimmedBranch.includes('..')) {
|
||||||
|
return res.status(400).json({ error: 'Invalid branch name' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await GitSyncHandler.setConfig(projectId, {
|
||||||
|
remoteUrl: trimmedUrl,
|
||||||
|
subPath: trimmedSubPath,
|
||||||
|
pdfPath: trimmedPdfPath,
|
||||||
|
pushFiles: Boolean(pushFiles),
|
||||||
|
pushPdf: Boolean(pushPdf),
|
||||||
|
branch: trimmedBranch,
|
||||||
|
})
|
||||||
|
logger.debug({ projectId }, 'git sync: config saved')
|
||||||
|
res.sendStatus(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushToGit(req, res) {
|
||||||
|
const projectId = req.params.project_id
|
||||||
|
try {
|
||||||
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
const { buildId, clsiServerId } = req.body ?? {}
|
||||||
|
const { remoteUrl, subPath, pdfPath, pushFiles, pushPdf, branch } =
|
||||||
|
await GitSyncHandler.getConfig(projectId)
|
||||||
|
if (!remoteUrl) {
|
||||||
|
return res.status(400).json({ error: 'No git remote configured for this project' })
|
||||||
|
}
|
||||||
|
logger.debug({ projectId }, 'git sync: starting push')
|
||||||
|
await GitSyncHandler.pushToRemote(projectId, remoteUrl, subPath, {
|
||||||
|
pdfPath,
|
||||||
|
pdfBuildId: buildId,
|
||||||
|
pdfClsiServerId: clsiServerId,
|
||||||
|
userId,
|
||||||
|
pushFiles,
|
||||||
|
pushPdf,
|
||||||
|
branch,
|
||||||
|
})
|
||||||
|
res.sendStatus(204)
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, projectId }, 'git sync: push failed')
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pullFromGit(req, res) {
|
||||||
|
const projectId = req.params.project_id
|
||||||
|
try {
|
||||||
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
const { remoteUrl, subPath } = await GitSyncHandler.getConfig(projectId)
|
||||||
|
if (!remoteUrl) {
|
||||||
|
return res.status(400).json({ error: 'No git remote configured for this project' })
|
||||||
|
}
|
||||||
|
logger.debug({ projectId }, 'git sync: starting pull')
|
||||||
|
await GitSyncHandler.pullFromRemote(projectId, remoteUrl, subPath, userId)
|
||||||
|
res.sendStatus(204)
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, projectId }, 'git sync: pull failed')
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGitSyncConfig(req, res) {
|
||||||
|
const projectId = req.params.project_id
|
||||||
|
const config = await GitSyncHandler.getConfig(projectId)
|
||||||
|
res.json({
|
||||||
|
remoteUrl: config.remoteUrl ?? '',
|
||||||
|
subPath: config.subPath,
|
||||||
|
pdfPath: config.pdfPath,
|
||||||
|
pushFiles: config.pushFiles,
|
||||||
|
pushPdf: config.pushPdf,
|
||||||
|
branch: config.branch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
configureGitSync: expressify(configureGitSync),
|
||||||
|
pushToGit: expressify(pushToGit),
|
||||||
|
pullFromGit: expressify(pullFromGit),
|
||||||
|
getGitSyncConfig: expressify(getGitSyncConfig),
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { mkdtemp, mkdir, writeFile, rm, readdir, readFile, access } from 'node:fs/promises'
|
||||||
|
import { createWriteStream } from 'node:fs'
|
||||||
|
import { join, dirname, extname, basename } from 'node:path'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { pipeline } from 'node:stream/promises'
|
||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import ProjectEntityHandler from '../Project/ProjectEntityHandler.mjs'
|
||||||
|
import ProjectEntityUpdateHandler from '../Project/ProjectEntityUpdateHandler.mjs'
|
||||||
|
import HistoryManager from '../History/HistoryManager.mjs'
|
||||||
|
import ClsiManager from '../Compile/ClsiManager.mjs'
|
||||||
|
import { Project } from '../../models/Project.mjs'
|
||||||
|
import { ObjectId } from '../../infrastructure/mongodb.mjs'
|
||||||
|
|
||||||
|
const TEXT_EXTENSIONS = new Set(
|
||||||
|
Settings.textExtensions.map(ext => `.${ext}`)
|
||||||
|
)
|
||||||
|
const EDITABLE_FILENAMES = new Set(
|
||||||
|
(Settings.editableFilenames || []).map(n => n.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
function isTextFile(filename) {
|
||||||
|
const ext = extname(filename).toLowerCase()
|
||||||
|
return TEXT_EXTENSIONS.has(ext) || EDITABLE_FILENAMES.has(filename.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walkDir(dir) {
|
||||||
|
const entries = await readdir(dir, { withFileTypes: true })
|
||||||
|
const files = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === '.git') continue
|
||||||
|
const fullPath = join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...(await walkDir(fullPath)))
|
||||||
|
} else {
|
||||||
|
files.push(fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spawnGit(args, cwd) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn('git', args, {
|
||||||
|
cwd,
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
})
|
||||||
|
let stderr = ''
|
||||||
|
proc.stderr?.on('data', d => (stderr += d.toString()))
|
||||||
|
proc.on('close', code => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`git ${args[0]} exited ${code}: ${stderr.trim()}`))
|
||||||
|
} else {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
proc.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setConfig(projectId, { remoteUrl, subPath, pdfPath, pushFiles, pushPdf, branch }) {
|
||||||
|
await Project.updateOne(
|
||||||
|
{ _id: new ObjectId(projectId) },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
gitRemote: remoteUrl,
|
||||||
|
gitSyncPath: subPath,
|
||||||
|
gitSyncPdfPath: pdfPath,
|
||||||
|
gitSyncPushFiles: pushFiles,
|
||||||
|
gitSyncPushPdf: pushPdf,
|
||||||
|
gitSyncBranch: branch,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getConfig(projectId) {
|
||||||
|
const project = await Project.findById(projectId, {
|
||||||
|
gitRemote: 1,
|
||||||
|
gitSyncPath: 1,
|
||||||
|
gitSyncPdfPath: 1,
|
||||||
|
gitSyncPushFiles: 1,
|
||||||
|
gitSyncPushPdf: 1,
|
||||||
|
gitSyncBranch: 1,
|
||||||
|
}).lean()
|
||||||
|
return {
|
||||||
|
remoteUrl: project?.gitRemote ?? null,
|
||||||
|
subPath: project?.gitSyncPath ?? '',
|
||||||
|
pdfPath: project?.gitSyncPdfPath ?? '',
|
||||||
|
pushFiles: project?.gitSyncPushFiles ?? true,
|
||||||
|
pushPdf: project?.gitSyncPushPdf ?? true,
|
||||||
|
branch: project?.gitSyncBranch ?? 'main',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushToRemote(
|
||||||
|
projectId,
|
||||||
|
remoteUrl,
|
||||||
|
subPath,
|
||||||
|
{ pdfPath, pdfBuildId, pdfClsiServerId, userId, pushFiles = true, pushPdf = true, branch = 'main' } = {}
|
||||||
|
) {
|
||||||
|
const tmpDir = await mkdtemp(join(tmpdir(), 'verso-git-'))
|
||||||
|
logger.debug({ projectId, tmpDir, subPath, pushFiles, pushPdf, branch }, 'git sync: starting push')
|
||||||
|
try {
|
||||||
|
const repoDir = join(tmpDir, 'repo')
|
||||||
|
await mkdir(repoDir)
|
||||||
|
|
||||||
|
// Init, connect to remote, fetch existing state so files outside our
|
||||||
|
// managed area are preserved in the push.
|
||||||
|
await spawnGit(['init'], repoDir)
|
||||||
|
await spawnGit(['symbolic-ref', 'HEAD', `refs/heads/${branch}`], repoDir)
|
||||||
|
await spawnGit(['config', 'user.email', 'verso-sync@localhost'], repoDir)
|
||||||
|
await spawnGit(['config', 'user.name', 'Verso Sync'], repoDir)
|
||||||
|
await spawnGit(['remote', 'add', 'origin', remoteUrl], repoDir)
|
||||||
|
try {
|
||||||
|
await spawnGit(['fetch', '--depth', '1', 'origin', branch], repoDir)
|
||||||
|
await spawnGit(['reset', '--hard', 'FETCH_HEAD'], repoDir)
|
||||||
|
} catch {
|
||||||
|
// Remote branch doesn't exist yet (first push to empty repo) — proceed
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileRoot = subPath ? join(repoDir, subPath) : repoDir
|
||||||
|
|
||||||
|
if (pushFiles) {
|
||||||
|
// Clear the managed area so files deleted in Verso are removed from git.
|
||||||
|
// With a subPath this leaves everything outside the subPath untouched.
|
||||||
|
// Without a subPath the entire repo is considered Verso-owned.
|
||||||
|
if (subPath) {
|
||||||
|
await rm(fileRoot, { recursive: true, force: true })
|
||||||
|
await mkdir(fileRoot, { recursive: true })
|
||||||
|
} else {
|
||||||
|
const entries = await readdir(repoDir, { withFileTypes: true })
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === '.git') continue
|
||||||
|
await rm(join(repoDir, entry.name), { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const docs = await ProjectEntityHandler.promises.getAllDocs(projectId)
|
||||||
|
for (const [path, doc] of Object.entries(docs)) {
|
||||||
|
const rel = path.startsWith('/') ? path.slice(1) : path
|
||||||
|
const dest = join(fileRoot, rel)
|
||||||
|
await mkdir(dirname(dest), { recursive: true })
|
||||||
|
await writeFile(dest, doc.lines.join('\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await ProjectEntityHandler.promises.getAllFiles(projectId)
|
||||||
|
for (const [path, file] of Object.entries(files)) {
|
||||||
|
const rel = path.startsWith('/') ? path.slice(1) : path
|
||||||
|
const dest = join(fileRoot, rel)
|
||||||
|
await mkdir(dirname(dest), { recursive: true })
|
||||||
|
const { stream } =
|
||||||
|
await HistoryManager.promises.requestBlobWithProjectId(
|
||||||
|
projectId,
|
||||||
|
file.hash
|
||||||
|
)
|
||||||
|
await pipeline(stream, createWriteStream(dest))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF path is always relative to repo root, independent of subPath
|
||||||
|
if (pushPdf && pdfPath && pdfBuildId) {
|
||||||
|
const pdfStream = await ClsiManager.promises.getOutputFileStream(
|
||||||
|
projectId,
|
||||||
|
userId,
|
||||||
|
pdfClsiServerId ?? undefined,
|
||||||
|
pdfBuildId,
|
||||||
|
'output.pdf'
|
||||||
|
)
|
||||||
|
const pdfDest = join(repoDir, pdfPath)
|
||||||
|
await mkdir(dirname(pdfDest), { recursive: true })
|
||||||
|
await pipeline(pdfStream, createWriteStream(pdfDest))
|
||||||
|
logger.debug({ projectId }, 'git sync: PDF included')
|
||||||
|
}
|
||||||
|
|
||||||
|
await spawnGit(['add', '-A'], repoDir)
|
||||||
|
await spawnGit(['commit', '--allow-empty', '-m', 'Verso sync'], repoDir)
|
||||||
|
await spawnGit(['push', '--force', 'origin', `HEAD:${branch}`], repoDir)
|
||||||
|
|
||||||
|
logger.debug({ projectId }, 'git sync: push complete')
|
||||||
|
} finally {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pullFromRemote(projectId, remoteUrl, subPath, userId) {
|
||||||
|
const tmpDir = await mkdtemp(join(tmpdir(), 'verso-pull-'))
|
||||||
|
logger.debug({ projectId, subPath }, 'git sync: cloning remote for pull')
|
||||||
|
try {
|
||||||
|
await spawnGit(['clone', '--depth', '1', '--', remoteUrl, 'repo'], tmpDir)
|
||||||
|
|
||||||
|
const repoDir = join(tmpDir, 'repo')
|
||||||
|
const fileRoot = subPath ? join(repoDir, subPath) : repoDir
|
||||||
|
|
||||||
|
// Verify the subdirectory exists in the repo
|
||||||
|
try {
|
||||||
|
await access(fileRoot)
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
subPath
|
||||||
|
? `Subdirectory "${subPath}" not found in the repository`
|
||||||
|
: 'Repository is empty'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFiles = await walkDir(fileRoot)
|
||||||
|
logger.debug({ projectId, count: allFiles.length }, 'git sync: importing files from remote')
|
||||||
|
|
||||||
|
for (const absPath of allFiles) {
|
||||||
|
// Path relative to fileRoot → Verso project path (leading slash required)
|
||||||
|
const rel = absPath.slice(fileRoot.length)
|
||||||
|
const projectPath = rel.startsWith('/') ? rel : `/${rel}`
|
||||||
|
|
||||||
|
if (isTextFile(basename(absPath))) {
|
||||||
|
const content = await readFile(absPath, 'utf8')
|
||||||
|
const lines = content.split('\n')
|
||||||
|
await ProjectEntityUpdateHandler.promises.upsertDocWithPath(
|
||||||
|
projectId,
|
||||||
|
projectPath,
|
||||||
|
lines,
|
||||||
|
'git-sync',
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
await ProjectEntityUpdateHandler.promises.upsertFileWithPath(
|
||||||
|
projectId,
|
||||||
|
projectPath,
|
||||||
|
absPath,
|
||||||
|
null,
|
||||||
|
userId,
|
||||||
|
'git-sync'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug({ projectId }, 'git sync: pull complete')
|
||||||
|
} finally {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { setConfig, getConfig, pushToRemote, pullFromRemote }
|
||||||
@@ -604,6 +604,12 @@ const _ProjectController = {
|
|||||||
brandVariationId: 1,
|
brandVariationId: 1,
|
||||||
overleaf: 1,
|
overleaf: 1,
|
||||||
tokens: 1,
|
tokens: 1,
|
||||||
|
gitRemote: 1,
|
||||||
|
gitSyncPath: 1,
|
||||||
|
gitSyncPdfPath: 1,
|
||||||
|
gitSyncPushFiles: 1,
|
||||||
|
gitSyncPushPdf: 1,
|
||||||
|
gitSyncBranch: 1,
|
||||||
}),
|
}),
|
||||||
userIsMemberOfGroupSubscription: sessionUser
|
userIsMemberOfGroupSubscription: sessionUser
|
||||||
? (async () =>
|
? (async () =>
|
||||||
@@ -1022,6 +1028,27 @@ const _ProjectController = {
|
|||||||
imageNames,
|
imageNames,
|
||||||
gitBridgePublicBaseUrl: Settings.gitBridgePublicBaseUrl,
|
gitBridgePublicBaseUrl: Settings.gitBridgePublicBaseUrl,
|
||||||
gitBridgeEnabled: Features.hasFeature('git-bridge'),
|
gitBridgeEnabled: Features.hasFeature('git-bridge'),
|
||||||
|
gitSyncEnabled:
|
||||||
|
Boolean(Settings.enableGitSync) &&
|
||||||
|
privilegeLevel === PrivilegeLevels.OWNER,
|
||||||
|
gitRemote: privilegeLevel === PrivilegeLevels.OWNER
|
||||||
|
? (project.gitRemote ?? '')
|
||||||
|
: '',
|
||||||
|
gitSyncPath: privilegeLevel === PrivilegeLevels.OWNER
|
||||||
|
? (project.gitSyncPath ?? '')
|
||||||
|
: '',
|
||||||
|
gitSyncPdfPath: privilegeLevel === PrivilegeLevels.OWNER
|
||||||
|
? (project.gitSyncPdfPath ?? '')
|
||||||
|
: '',
|
||||||
|
gitSyncPushFiles: privilegeLevel === PrivilegeLevels.OWNER
|
||||||
|
? (project.gitSyncPushFiles ?? true)
|
||||||
|
: true,
|
||||||
|
gitSyncPushPdf: privilegeLevel === PrivilegeLevels.OWNER
|
||||||
|
? (project.gitSyncPushPdf ?? true)
|
||||||
|
: true,
|
||||||
|
gitSyncBranch: privilegeLevel === PrivilegeLevels.OWNER
|
||||||
|
? (project.gitSyncBranch ?? 'main')
|
||||||
|
: 'main',
|
||||||
wsUrl,
|
wsUrl,
|
||||||
showSupport: Features.hasFeature('support'),
|
showSupport: Features.hasFeature('support'),
|
||||||
showTemplatesServerPro,
|
showTemplatesServerPro,
|
||||||
@@ -1364,6 +1391,7 @@ const _ProjectController = {
|
|||||||
function getInitialLoadingScreenTheme(overallThemeSetting) {
|
function getInitialLoadingScreenTheme(overallThemeSetting) {
|
||||||
switch (overallThemeSetting) {
|
switch (overallThemeSetting) {
|
||||||
case 'light-':
|
case 'light-':
|
||||||
|
case 'lumiere-':
|
||||||
return 'light'
|
return 'light'
|
||||||
case '':
|
case '':
|
||||||
return 'dark'
|
return 'dark'
|
||||||
|
|||||||
@@ -51,24 +51,20 @@ async function setRootDocAutomatically(projectId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function findRootDocFileFromDirectory(directoryPath) {
|
async function findRootDocFileFromDirectory(directoryPath) {
|
||||||
const unsortedFiles = await globby(['**/*.{tex,Rtex,Rnw}'], {
|
// First try LaTeX files (look for \documentclass)
|
||||||
|
const unsortedTexFiles = await globby(['**/*.{tex,Rtex,Rnw,ltx}'], {
|
||||||
cwd: directoryPath,
|
cwd: directoryPath,
|
||||||
followSymlinkedDirectories: false,
|
followSymlinkedDirectories: false,
|
||||||
onlyFiles: true,
|
onlyFiles: true,
|
||||||
case: false,
|
case: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// the search order is such that we prefer files closer to the project root, then
|
const texFiles = await _sortFileList(unsortedTexFiles, directoryPath)
|
||||||
// we go by file size in ascending order, because people often have a main
|
let firstTexInRootFolder
|
||||||
// file that just includes a bunch of other files; then we go by name, in
|
|
||||||
// order to be deterministic
|
|
||||||
|
|
||||||
const files = await _sortFileList(unsortedFiles, directoryPath)
|
|
||||||
let firstFileInRootFolder
|
|
||||||
let doc = null
|
let doc = null
|
||||||
|
|
||||||
while (files.length > 0 && doc == null) {
|
while (texFiles.length > 0 && doc == null) {
|
||||||
const file = files.shift()
|
const file = texFiles.shift()
|
||||||
const content = await fs.promises.readFile(
|
const content = await fs.promises.readFile(
|
||||||
Path.join(directoryPath, file),
|
Path.join(directoryPath, file),
|
||||||
'utf8'
|
'utf8'
|
||||||
@@ -77,18 +73,52 @@ async function findRootDocFileFromDirectory(directoryPath) {
|
|||||||
if (DocumentHelper.contentHasDocumentclass(normalizedContent)) {
|
if (DocumentHelper.contentHasDocumentclass(normalizedContent)) {
|
||||||
doc = { path: file, content: normalizedContent }
|
doc = { path: file, content: normalizedContent }
|
||||||
}
|
}
|
||||||
|
if (!firstTexInRootFolder && !file.includes('/')) {
|
||||||
if (!firstFileInRootFolder && !file.includes('/')) {
|
firstTexInRootFolder = { path: file, content: normalizedContent }
|
||||||
firstFileInRootFolder = { path: file, content: normalizedContent }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if no doc was found, use the first file in the root folder as the main doc
|
if (!doc && firstTexInRootFolder) {
|
||||||
if (!doc && firstFileInRootFolder) {
|
doc = firstTexInRootFolder
|
||||||
doc = firstFileInRootFolder
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { path: doc?.path, content: doc?.content }
|
if (doc) return { path: doc.path, content: doc.content }
|
||||||
|
|
||||||
|
// Then try Typst files
|
||||||
|
const typFiles = await globby(['*.typ'], {
|
||||||
|
cwd: directoryPath,
|
||||||
|
followSymlinkedDirectories: false,
|
||||||
|
onlyFiles: true,
|
||||||
|
case: false,
|
||||||
|
})
|
||||||
|
if (typFiles.length > 0) {
|
||||||
|
typFiles.sort()
|
||||||
|
const file = typFiles[0]
|
||||||
|
const content = await fs.promises.readFile(
|
||||||
|
Path.join(directoryPath, file),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
return { path: file, content }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then try Quarto/R Markdown files
|
||||||
|
const qmdFiles = await globby(['*.{qmd,Rmd,rmd}'], {
|
||||||
|
cwd: directoryPath,
|
||||||
|
followSymlinkedDirectories: false,
|
||||||
|
onlyFiles: true,
|
||||||
|
case: false,
|
||||||
|
})
|
||||||
|
if (qmdFiles.length > 0) {
|
||||||
|
qmdFiles.sort()
|
||||||
|
const file = qmdFiles[0]
|
||||||
|
const content = await fs.promises.readFile(
|
||||||
|
Path.join(directoryPath, file),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
return { path: file, content }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path: undefined, content: undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setRootDocFromName(projectId, rootDocName) {
|
async function setRootDocFromName(projectId, rootDocName) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const SYSTEM_THEME_USER_CUTOFF_DATE = new Date(Date.UTC(2026, 2, 2, 12, 0, 0)) // 12pm GMT on March 2, 2026
|
const SYSTEM_THEME_USER_CUTOFF_DATE = new Date(Date.UTC(2026, 2, 2, 12, 0, 0)) // 12pm GMT on March 2, 2026
|
||||||
|
const LUMIERE_THEME_USER_CUTOFF_DATE = new Date(Date.UTC(2026, 5, 11, 12, 0, 0)) // 12pm GMT on June 11, 2026
|
||||||
|
|
||||||
function getOverallTheme(user) {
|
function getOverallTheme(user) {
|
||||||
if (user.ace.overallTheme != null) {
|
if (user.ace.overallTheme != null) {
|
||||||
@@ -10,7 +11,11 @@ function getOverallTheme(user) {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'system'
|
if (user.signUpDate < LUMIERE_THEME_USER_CUTOFF_DATE) {
|
||||||
|
return 'system'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'lumiere-'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildUserSettings(_req, _res, user) {
|
async function buildUserSettings(_req, _res, user) {
|
||||||
@@ -41,4 +46,5 @@ async function buildUserSettings(_req, _res, user) {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
buildUserSettings,
|
buildUserSettings,
|
||||||
|
getOverallTheme,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
|
|||||||
|
|
||||||
const defaultsDeep = lodash.defaultsDeep
|
const defaultsDeep = lodash.defaultsDeep
|
||||||
|
|
||||||
|
// Send a JSON response compatible with both normal mode and streaming mode
|
||||||
|
// (where startStreamingResponse already sent HTTP 200 + chunked headers).
|
||||||
|
function sendUploadResponse(res, statusCode, body) {
|
||||||
|
if (res.headersSent) {
|
||||||
|
return res.end(JSON.stringify(body))
|
||||||
|
}
|
||||||
|
return res.status(statusCode).json(body)
|
||||||
|
}
|
||||||
|
|
||||||
const upload = multer(
|
const upload = multer(
|
||||||
defaultsDeep(
|
defaultsDeep(
|
||||||
{
|
{
|
||||||
@@ -92,7 +101,7 @@ async function uploadFile(req, res, next) {
|
|||||||
await fsPromises.unlink(path).catch(unlinkErr => {
|
await fsPromises.unlink(path).catch(unlinkErr => {
|
||||||
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
|
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
|
||||||
})
|
})
|
||||||
return res.status(422).json({
|
return sendUploadResponse(res, 422, {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'invalid_filename',
|
error: 'invalid_filename',
|
||||||
})
|
})
|
||||||
@@ -119,7 +128,7 @@ async function uploadFile(req, res, next) {
|
|||||||
await fsPromises.unlink(path).catch(unlinkErr => {
|
await fsPromises.unlink(path).catch(unlinkErr => {
|
||||||
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
|
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
|
||||||
})
|
})
|
||||||
throw error
|
return sendUploadResponse(res, 500, { success: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
return FileSystemImportManager.addEntity(
|
return FileSystemImportManager.addEntity(
|
||||||
@@ -134,22 +143,22 @@ async function uploadFile(req, res, next) {
|
|||||||
timer.done()
|
timer.done()
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
if (error.name === 'InvalidNameError') {
|
if (error.name === 'InvalidNameError') {
|
||||||
return res.status(422).json({
|
return sendUploadResponse(res, 422, {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'invalid_filename',
|
error: 'invalid_filename',
|
||||||
})
|
})
|
||||||
} else if (error instanceof DuplicateNameError) {
|
} else if (error instanceof DuplicateNameError) {
|
||||||
return res.status(422).json({
|
return sendUploadResponse(res, 422, {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'duplicate_file_name',
|
error: 'duplicate_file_name',
|
||||||
})
|
})
|
||||||
} else if (error.message === 'project_has_too_many_files') {
|
} else if (error.message === 'project_has_too_many_files') {
|
||||||
return res.status(422).json({
|
return sendUploadResponse(res, 422, {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'project_has_too_many_files',
|
error: 'project_has_too_many_files',
|
||||||
})
|
})
|
||||||
} else if (error.message === 'folder_not_found') {
|
} else if (error.message === 'folder_not_found') {
|
||||||
return res.status(422).json({
|
return sendUploadResponse(res, 422, {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'folder_not_found',
|
error: 'folder_not_found',
|
||||||
})
|
})
|
||||||
@@ -164,10 +173,10 @@ async function uploadFile(req, res, next) {
|
|||||||
},
|
},
|
||||||
'error uploading file'
|
'error uploading file'
|
||||||
)
|
)
|
||||||
return res.status(422).json({ success: false })
|
return sendUploadResponse(res, 422, { success: false })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return res.json({
|
return sendUploadResponse(res, 200, {
|
||||||
success: true,
|
success: true,
|
||||||
entity_id: entity?._id,
|
entity_id: entity?._id,
|
||||||
entity_type: entity?.type,
|
entity_type: entity?.type,
|
||||||
@@ -187,7 +196,7 @@ async function importDocument(req, res, next) {
|
|||||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
const { path } = req.file
|
const { path } = req.file
|
||||||
const conversionType = req.query.type
|
const conversionType = req.query.type
|
||||||
if (!['docx', 'markdown'].includes(conversionType)) {
|
if (!['docx', 'markdown', 'typst'].includes(conversionType)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: req.i18n.translate('invalid_import_type'),
|
error: req.i18n.translate('invalid_import_type'),
|
||||||
@@ -273,25 +282,28 @@ async function importDocument(req, res, next) {
|
|||||||
*/
|
*/
|
||||||
function multerMiddleware(req, res, next) {
|
function multerMiddleware(req, res, next) {
|
||||||
if (upload == null) {
|
if (upload == null) {
|
||||||
return res
|
return sendUploadResponse(res, 500, {
|
||||||
.status(500)
|
success: false,
|
||||||
.json({ success: false, error: req.i18n.translate('upload_failed') })
|
error: req.i18n.translate('upload_failed'),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return upload.single('qqfile')(
|
return upload.single('qqfile')(
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
/** @param {any} err */ function (err) {
|
/** @param {any} err */ function (err) {
|
||||||
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
|
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
|
||||||
return res
|
return sendUploadResponse(res, 422, {
|
||||||
.status(422)
|
success: false,
|
||||||
.json({ success: false, error: req.i18n.translate('file_too_large') })
|
error: req.i18n.translate('file_too_large'),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (err) return next(err)
|
if (err) return next(err)
|
||||||
if (!req.file?.path) {
|
if (!req.file?.path) {
|
||||||
logger.info({ req }, 'missing req.file.path on upload')
|
logger.info({ req }, 'missing req.file.path on upload')
|
||||||
return res
|
return sendUploadResponse(res, 400, {
|
||||||
.status(400)
|
success: false,
|
||||||
.json({ success: false, error: 'invalid_upload_request' })
|
error: 'invalid_upload_request',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,21 @@ import ProjectRootDocManager from '../Project/ProjectRootDocManager.mjs'
|
|||||||
import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.mjs'
|
import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.mjs'
|
||||||
import ProjectDeleter from '../Project/ProjectDeleter.mjs'
|
import ProjectDeleter from '../Project/ProjectDeleter.mjs'
|
||||||
import { DeletedProjectReasons } from '../Project/DeletedProjectReasons.mjs'
|
import { DeletedProjectReasons } from '../Project/DeletedProjectReasons.mjs'
|
||||||
|
import ProjectOptionsHandler from '../Project/ProjectOptionsHandler.mjs'
|
||||||
import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.mjs'
|
import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.mjs'
|
||||||
import logger from '@overleaf/logger'
|
import logger from '@overleaf/logger'
|
||||||
import OError from '@overleaf/o-error'
|
import OError from '@overleaf/o-error'
|
||||||
|
|
||||||
|
const COMPILER_BY_EXTENSION = {
|
||||||
|
tex: 'pdflatex',
|
||||||
|
rtex: 'pdflatex',
|
||||||
|
ltx: 'pdflatex',
|
||||||
|
rnw: 'pdflatex',
|
||||||
|
typ: 'typst',
|
||||||
|
qmd: 'quarto',
|
||||||
|
rmd: 'quarto',
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
createProjectFromZipArchive: callbackify(createProjectFromZipArchive),
|
createProjectFromZipArchive: callbackify(createProjectFromZipArchive),
|
||||||
createProjectFromZipArchiveWithName: callbackify(
|
createProjectFromZipArchiveWithName: callbackify(
|
||||||
@@ -52,6 +63,11 @@ async function createProjectFromZipArchive(ownerId, defaultName, zipPath) {
|
|||||||
project._id,
|
project._id,
|
||||||
path
|
path
|
||||||
)
|
)
|
||||||
|
const ext = path.split('.').pop()?.toLowerCase()
|
||||||
|
const compiler = ext ? COMPILER_BY_EXTENSION[ext] : null
|
||||||
|
if (compiler) {
|
||||||
|
await ProjectOptionsHandler.promises.setCompiler(project._id, compiler)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// no need to wait for the cleanup here
|
// no need to wait for the cleanup here
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
|
|||||||
import Settings from '@overleaf/settings'
|
import Settings from '@overleaf/settings'
|
||||||
import AsyncLocalStorage from '../../infrastructure/AsyncLocalStorage.mjs'
|
import AsyncLocalStorage from '../../infrastructure/AsyncLocalStorage.mjs'
|
||||||
|
|
||||||
|
// Sends HTTP 200 + first chunk immediately so upstream proxies (Traefik, cloud
|
||||||
|
// LBs) don't timeout waiting for the first response byte during large uploads.
|
||||||
|
// Must come *after* auth/rate-limit middleware (those still return proper codes)
|
||||||
|
// but *before* multer so it fires while the request body is still streaming in.
|
||||||
|
// The upload handler ends the response with the actual JSON result as the final
|
||||||
|
// chunk; the client's getResponseData trims leading whitespace before parsing.
|
||||||
|
function startStreamingResponse(req, res, next) {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Transfer-Encoding': 'chunked',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
})
|
||||||
|
res.write('\n')
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
if (!res.writableEnded) res.write('\n')
|
||||||
|
}, 30000)
|
||||||
|
res.on('finish', () => clearInterval(heartbeat))
|
||||||
|
res.on('close', () => clearInterval(heartbeat))
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
const rateLimiters = {
|
const rateLimiters = {
|
||||||
projectUpload: new RateLimiter('project-upload', {
|
projectUpload: new RateLimiter('project-upload', {
|
||||||
points: 20,
|
points: 20,
|
||||||
@@ -62,6 +83,7 @@ export default {
|
|||||||
fileUploadRateLimit,
|
fileUploadRateLimit,
|
||||||
AsyncLocalStorage.middleware,
|
AsyncLocalStorage.middleware,
|
||||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||||
|
startStreamingResponse,
|
||||||
ProjectUploadController.multerMiddleware,
|
ProjectUploadController.multerMiddleware,
|
||||||
ProjectUploadController.uploadFile
|
ProjectUploadController.uploadFile
|
||||||
)
|
)
|
||||||
@@ -72,6 +94,7 @@ export default {
|
|||||||
AuthenticationController.requireLogin(),
|
AuthenticationController.requireLogin(),
|
||||||
AsyncLocalStorage.middleware,
|
AsyncLocalStorage.middleware,
|
||||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||||
|
startStreamingResponse,
|
||||||
ProjectUploadController.multerMiddleware,
|
ProjectUploadController.multerMiddleware,
|
||||||
ProjectUploadController.uploadFile
|
ProjectUploadController.uploadFile
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import Translations from '../../infrastructure/Translations.mjs'
|
||||||
import UserHandler from './UserHandler.mjs'
|
import UserHandler from './UserHandler.mjs'
|
||||||
import UserDeleter from './UserDeleter.mjs'
|
import UserDeleter from './UserDeleter.mjs'
|
||||||
import UserGetter from './UserGetter.mjs'
|
import UserGetter from './UserGetter.mjs'
|
||||||
@@ -562,6 +564,30 @@ async function expireDeletedUsersAfterDuration(req, res, next) {
|
|||||||
res.sendStatus(204)
|
res.sendStatus(204)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setLanguage(req, res) {
|
||||||
|
const lngCode = req.body.lngCode ?? req.query.lng
|
||||||
|
if (!Translations.availableLanguageCodes.includes(lngCode)) {
|
||||||
|
return res.status(400).end()
|
||||||
|
}
|
||||||
|
res.cookie(Translations.LANG_COOKIE_NAME, lngCode, {
|
||||||
|
maxAge: 365 * 24 * 60 * 60 * 1000,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: Settings.secureCookie,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
domain: Settings.cookieDomain,
|
||||||
|
})
|
||||||
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
if (userId) {
|
||||||
|
await User.findByIdAndUpdate(userId, { languageCode: lngCode }).exec()
|
||||||
|
}
|
||||||
|
const returnTo = req.query.return_to
|
||||||
|
const redir =
|
||||||
|
typeof returnTo === 'string' && returnTo.startsWith('/')
|
||||||
|
? returnTo
|
||||||
|
: req.get('Referer') || '/project'
|
||||||
|
res.redirect(302, redir)
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
clearSessions: expressify(clearSessions),
|
clearSessions: expressify(clearSessions),
|
||||||
changePassword: expressify(changePassword),
|
changePassword: expressify(changePassword),
|
||||||
@@ -574,4 +600,5 @@ export default {
|
|||||||
expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration),
|
expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration),
|
||||||
ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware),
|
ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware),
|
||||||
ensureAffiliation,
|
ensureAffiliation,
|
||||||
|
setLanguage: expressify(setLanguage),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const buildViewPolicy = (
|
|||||||
viewDirectives
|
viewDirectives
|
||||||
) => {
|
) => {
|
||||||
const directives = [
|
const directives = [
|
||||||
`script-src 'nonce-${scriptNonce}' 'unsafe-inline' 'strict-dynamic' https: 'report-sample'`, // only allow scripts from certain sources
|
`script-src 'nonce-${scriptNonce}' 'unsafe-inline' 'strict-dynamic' 'wasm-unsafe-eval' https: 'report-sample'`, // only allow scripts from certain sources
|
||||||
`object-src 'none'`, // forbid loading an "object" element
|
`object-src 'none'`, // forbid loading an "object" element
|
||||||
`base-uri 'none'`, // forbid setting a "base" element
|
`base-uri 'none'`, // forbid setting a "base" element
|
||||||
...(viewDirectives ?? []),
|
...(viewDirectives ?? []),
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { fetchJson } from '@overleaf/fetch-utils'
|
|||||||
import contentDisposition from 'content-disposition'
|
import contentDisposition from 'content-disposition'
|
||||||
import Features from './Features.mjs'
|
import Features from './Features.mjs'
|
||||||
import SessionManager from '../Features/Authentication/SessionManager.mjs'
|
import SessionManager from '../Features/Authentication/SessionManager.mjs'
|
||||||
|
import UserGetter from '../Features/User/UserGetter.mjs'
|
||||||
|
import UserSettingsHelper from '../Features/Project/UserSettingsHelper.mjs'
|
||||||
import PackageVersions from './PackageVersions.js'
|
import PackageVersions from './PackageVersions.js'
|
||||||
import Modules from './Modules.mjs'
|
import Modules from './Modules.mjs'
|
||||||
import Errors from '../Features/Errors/Errors.js'
|
import Errors from '../Features/Errors/Errors.js'
|
||||||
@@ -15,6 +17,7 @@ import AdminAuthorizationHelper from '../Features/Helpers/AdminAuthorizationHelp
|
|||||||
import { addOptionalCleanupHandlerAfterDrainingConnections } from './GracefulShutdown.mjs'
|
import { addOptionalCleanupHandlerAfterDrainingConnections } from './GracefulShutdown.mjs'
|
||||||
import { sanitizeSessionUserForFrontEnd } from './FrontEndUser.mjs'
|
import { sanitizeSessionUserForFrontEnd } from './FrontEndUser.mjs'
|
||||||
import { expressify } from '@overleaf/promise-utils'
|
import { expressify } from '@overleaf/promise-utils'
|
||||||
|
import Translations from './Translations.mjs'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
canRedirectToAdminDomain,
|
canRedirectToAdminDomain,
|
||||||
@@ -269,6 +272,28 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
|
|||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
webRouter.use(
|
||||||
|
expressify(async function (req, res, next) {
|
||||||
|
res.locals.isLumiere = false
|
||||||
|
const sessionUser = SessionManager.getSessionUser(req.session)
|
||||||
|
if (sessionUser?._id) {
|
||||||
|
try {
|
||||||
|
const user = await UserGetter.promises.getUser(sessionUser._id, {
|
||||||
|
'ace.overallTheme': 1,
|
||||||
|
signUpDate: 1,
|
||||||
|
})
|
||||||
|
if (user) {
|
||||||
|
res.locals.isLumiere =
|
||||||
|
UserSettingsHelper.getOverallTheme(user) === 'lumiere-'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err }, 'failed to fetch theme for isLumiere')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
webRouter.use(function (req, res, next) {
|
webRouter.use(function (req, res, next) {
|
||||||
res.locals.getLoggedInUserId = () =>
|
res.locals.getLoggedInUserId = () =>
|
||||||
SessionManager.getLoggedInUserId(req.session)
|
SessionManager.getLoggedInUserId(req.session)
|
||||||
@@ -307,11 +332,15 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
|
|||||||
webRouter.use(function (req, res, next) {
|
webRouter.use(function (req, res, next) {
|
||||||
res.locals.overallThemes = [
|
res.locals.overallThemes = [
|
||||||
{
|
{
|
||||||
name: 'Dark',
|
name: 'Verso Lumière',
|
||||||
|
val: 'lumiere-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Classic Dark',
|
||||||
val: '',
|
val: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Light',
|
name: 'Classic Light',
|
||||||
val: 'light-',
|
val: 'light-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -324,6 +353,7 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
|
|||||||
|
|
||||||
webRouter.use(function (req, res, next) {
|
webRouter.use(function (req, res, next) {
|
||||||
res.locals.settings = Settings
|
res.locals.settings = Settings
|
||||||
|
res.locals.availableLanguages = Translations.availableLanguageCodes
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ if (Settings.csp && Settings.csp.enabled) {
|
|||||||
|
|
||||||
logger.debug('creating HTTP server'.yellow)
|
logger.debug('creating HTTP server'.yellow)
|
||||||
const server = http.createServer(app)
|
const server = http.createServer(app)
|
||||||
|
server.requestTimeout = 15 * 60 * 1000
|
||||||
|
|
||||||
// provide settings for separate web and api processes
|
// provide settings for separate web and api processes
|
||||||
if (Settings.enabledServices.includes('api')) {
|
if (Settings.enabledServices.includes('api')) {
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ const locales = {
|
|||||||
'zh-CN': zhCN,
|
'zh-CN': zhCN,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LANG_COOKIE_NAME = 'verso-lang'
|
||||||
|
|
||||||
const fallbackLanguageCode = Settings.i18n.defaultLng || 'en'
|
const fallbackLanguageCode = Settings.i18n.defaultLng || 'en'
|
||||||
const availableLanguageCodes = []
|
const availableLanguageCodes = []
|
||||||
const availableHosts = new Map()
|
const availableHosts = new Map()
|
||||||
@@ -62,15 +64,20 @@ Object.values(Settings.i18n.subdomainLang || {}).forEach(function (spec) {
|
|||||||
subdomainConfigs.set(spec.lngCode, spec)
|
subdomainConfigs.set(spec.lngCode, spec)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (!availableLanguageCodes.includes(fallbackLanguageCode)) {
|
// Make all bundled locale files available regardless of subdomain config.
|
||||||
// always load the fallback locale
|
// This allows the cookie-based language picker to offer every loaded locale
|
||||||
availableLanguageCodes.push(fallbackLanguageCode)
|
// even when no subdomains are configured.
|
||||||
|
for (const lngCode of Object.keys(locales)) {
|
||||||
|
if (!availableLanguageCodes.includes(lngCode)) {
|
||||||
|
availableLanguageCodes.push(lngCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resources = Object.fromEntries(
|
const resources = Object.fromEntries(
|
||||||
Object.entries(locales)
|
Object.entries(locales).map(([lngCode, translations]) => [
|
||||||
.filter(([lngCode]) => availableLanguageCodes.includes(lngCode))
|
lngCode,
|
||||||
.map(([lngCode, translations]) => [lngCode, { translation: translations }])
|
{ translation: translations },
|
||||||
|
])
|
||||||
)
|
)
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
@@ -107,8 +114,13 @@ i18n
|
|||||||
})
|
})
|
||||||
|
|
||||||
function setLangBasedOnDomainMiddleware(req, res, next) {
|
function setLangBasedOnDomainMiddleware(req, res, next) {
|
||||||
// Determine language from subdomain
|
// Priority: (1) user-set cookie, (2) subdomain, (3) env-var default
|
||||||
const lang = availableHosts.get(req.headers.host) ?? fallbackLanguageCode
|
const cookieLng = req.cookies[LANG_COOKIE_NAME]
|
||||||
|
const hostLng = availableHosts.get(req.headers.host)
|
||||||
|
const lang =
|
||||||
|
(availableLanguageCodes.includes(cookieLng) ? cookieLng : null) ??
|
||||||
|
hostLng ??
|
||||||
|
fallbackLanguageCode
|
||||||
|
|
||||||
req.i18n = {
|
req.i18n = {
|
||||||
language: lang,
|
language: lang,
|
||||||
@@ -173,4 +185,6 @@ i18n.translate = i18n.t
|
|||||||
export default {
|
export default {
|
||||||
setLangBasedOnDomainMiddleware,
|
setLangBasedOnDomainMiddleware,
|
||||||
i18n,
|
i18n,
|
||||||
|
LANG_COOKIE_NAME,
|
||||||
|
availableLanguageCodes,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,12 @@ export const ProjectSchema = new Schema(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
deferredTpdsFlushCounter: { type: Number },
|
deferredTpdsFlushCounter: { type: Number },
|
||||||
|
gitRemote: { type: String },
|
||||||
|
gitSyncPath: { type: String },
|
||||||
|
gitSyncPdfPath: { type: String },
|
||||||
|
gitSyncPushFiles: { type: Boolean },
|
||||||
|
gitSyncPushPdf: { type: Boolean },
|
||||||
|
gitSyncBranch: { type: String },
|
||||||
},
|
},
|
||||||
{ minimize: false }
|
{ minimize: false }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const UserSchema = new Schema(
|
|||||||
},
|
},
|
||||||
role: { type: String, default: '' },
|
role: { type: String, default: '' },
|
||||||
institution: { type: String, default: '' },
|
institution: { type: String, default: '' },
|
||||||
|
languageCode: { type: String, default: null },
|
||||||
hashedPassword: String,
|
hashedPassword: String,
|
||||||
enrollment: {
|
enrollment: {
|
||||||
sso: [
|
sso: [
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import { plainTextResponse } from './infrastructure/Response.mjs'
|
|||||||
import SocketDiagnostics from './Features/SocketDiagnostics/SocketDiagnostics.mjs'
|
import SocketDiagnostics from './Features/SocketDiagnostics/SocketDiagnostics.mjs'
|
||||||
import ClsiCacheController from './Features/Compile/ClsiCacheController.mjs'
|
import ClsiCacheController from './Features/Compile/ClsiCacheController.mjs'
|
||||||
import AsyncLocalStorage from './infrastructure/AsyncLocalStorage.mjs'
|
import AsyncLocalStorage from './infrastructure/AsyncLocalStorage.mjs'
|
||||||
|
import GitSyncController from './Features/GitSync/GitSyncController.mjs'
|
||||||
|
|
||||||
const { renderUnsupportedBrowserPage, unsupportedBrowserMiddleware } =
|
const { renderUnsupportedBrowserPage, unsupportedBrowserMiddleware } =
|
||||||
UnsupportedBrowserMiddleware
|
UnsupportedBrowserMiddleware
|
||||||
@@ -284,6 +285,14 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||||||
'/read-only/one-time-login'
|
'/read-only/one-time-login'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Language preference — works for both anonymous (GET) and logged-in (POST)
|
||||||
|
webRouter.get('/set-language', UserController.setLanguage)
|
||||||
|
webRouter.post(
|
||||||
|
'/user/language',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
UserController.setLanguage
|
||||||
|
)
|
||||||
|
|
||||||
webRouter.get('/logout', UserPagesController.logout)
|
webRouter.get('/logout', UserPagesController.logout)
|
||||||
webRouter.post('/logout', UserController.logout)
|
webRouter.post('/logout', UserController.logout)
|
||||||
|
|
||||||
@@ -595,6 +604,31 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||||||
ProjectController.updateProjectAdminSettings
|
ProjectController.updateProjectAdminSettings
|
||||||
)
|
)
|
||||||
|
|
||||||
|
webRouter.get(
|
||||||
|
'/project/:project_id/git-sync',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
AuthorizationMiddleware.ensureUserCanAdminProject,
|
||||||
|
GitSyncController.getGitSyncConfig
|
||||||
|
)
|
||||||
|
webRouter.post(
|
||||||
|
'/project/:project_id/git-sync',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
AuthorizationMiddleware.ensureUserCanAdminProject,
|
||||||
|
GitSyncController.configureGitSync
|
||||||
|
)
|
||||||
|
webRouter.post(
|
||||||
|
'/project/:project_id/git-sync/push',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||||
|
GitSyncController.pushToGit
|
||||||
|
)
|
||||||
|
webRouter.post(
|
||||||
|
'/project/:project_id/git-sync/pull',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||||
|
GitSyncController.pullFromGit
|
||||||
|
)
|
||||||
|
|
||||||
webRouter.post(
|
webRouter.post(
|
||||||
'/project/:Project_id/compile',
|
'/project/:Project_id/compile',
|
||||||
RateLimiterMiddleware.rateLimit(rateLimiters.compileProjectHttp, {
|
RateLimiterMiddleware.rateLimit(rateLimiters.compileProjectHttp, {
|
||||||
@@ -611,6 +645,12 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||||||
CompileController.stopCompile
|
CompileController.stopCompile
|
||||||
)
|
)
|
||||||
|
|
||||||
|
webRouter.get(
|
||||||
|
'/project/:Project_id/thumbnail',
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||||
|
CompileController.getProjectThumbnail
|
||||||
|
)
|
||||||
|
|
||||||
webRouter.get(
|
webRouter.get(
|
||||||
'/project/:Project_id/output/cached/output.overleaf.json',
|
'/project/:Project_id/output/cached/output.overleaf.json',
|
||||||
AsyncLocalStorage.middleware,
|
AsyncLocalStorage.middleware,
|
||||||
@@ -681,17 +721,17 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||||||
)
|
)
|
||||||
webRouter.post(
|
webRouter.post(
|
||||||
'/project/:Project_id/publish-presentation',
|
'/project/:Project_id/publish-presentation',
|
||||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
AuthorizationMiddleware.ensureUserCanAdminProject,
|
||||||
PublishedPresentationController.publish
|
PublishedPresentationController.publish
|
||||||
)
|
)
|
||||||
webRouter.post(
|
webRouter.post(
|
||||||
'/project/:Project_id/publish-presentation/regenerate',
|
'/project/:Project_id/publish-presentation/regenerate',
|
||||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
AuthorizationMiddleware.ensureUserCanAdminProject,
|
||||||
PublishedPresentationController.regenerate
|
PublishedPresentationController.regenerate
|
||||||
)
|
)
|
||||||
webRouter.delete(
|
webRouter.delete(
|
||||||
'/project/:Project_id/publish-presentation',
|
'/project/:Project_id/publish-presentation',
|
||||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
AuthorizationMiddleware.ensureUserCanAdminProject,
|
||||||
PublishedPresentationController.unpublish
|
PublishedPresentationController.unpublish
|
||||||
)
|
)
|
||||||
// On-demand export of a RevealJS deck (download menu): html | pdf.
|
// On-demand export of a RevealJS deck (download menu): html | pdf.
|
||||||
@@ -822,6 +862,12 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||||
ProjectDownloadsController.downloadPreparedProjectExport
|
ProjectDownloadsController.downloadPreparedProjectExport
|
||||||
)
|
)
|
||||||
|
webRouter.post(
|
||||||
|
'/project/:Project_id/doc/:Doc_id/convert/:type',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||||
|
ProjectDownloadsController.convertDocInProject
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
webRouter.get(
|
webRouter.get(
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ else if settings.overleaf
|
|||||||
meta(itemprop='image' content=buildImgPath('ol-brand/overleaf_og_logo.png'))
|
meta(itemprop='image' content=buildImgPath('ol-brand/overleaf_og_logo.png'))
|
||||||
meta(name='image' content=buildImgPath('ol-brand/overleaf_og_logo.png'))
|
meta(name='image' content=buildImgPath('ol-brand/overleaf_og_logo.png'))
|
||||||
else
|
else
|
||||||
//- the default image for Overleaf Community Edition/Server Pro
|
//- the default image for Verso Community Edition
|
||||||
meta(itemprop='image' content=buildBaseAssetPath() + 'apple-touch-icon.png')
|
meta(itemprop='image' content=buildBaseAssetPath() + 'og-image.png')
|
||||||
meta(name='image' content=buildBaseAssetPath() + 'apple-touch-icon.png')
|
meta(name='image' content=buildBaseAssetPath() + 'og-image.png')
|
||||||
|
|
||||||
//- Keywords
|
//- Keywords
|
||||||
if metadata && metadata.keywords
|
if metadata && metadata.keywords
|
||||||
@@ -78,14 +78,17 @@ else if settings.overleaf
|
|||||||
content=buildImgPath('ol-brand/overleaf_og_logo.png')
|
content=buildImgPath('ol-brand/overleaf_og_logo.png')
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
//- the default image for Overleaf Community Edition/Server Pro
|
//- the default image for Verso Community Edition
|
||||||
meta(
|
meta(
|
||||||
name='twitter:image'
|
name='twitter:image'
|
||||||
content=buildBaseAssetPath() + 'apple-touch-icon.png'
|
content=buildBaseAssetPath() + 'og-image.png'
|
||||||
)
|
)
|
||||||
|
|
||||||
//- Open Graph
|
//- Open Graph
|
||||||
//- to do - add og:url
|
if metadata && metadata.canonicalURL
|
||||||
|
meta(property='og:url' content=metadata.canonicalURL)
|
||||||
|
else if typeof currentUrl !== 'undefined'
|
||||||
|
meta(property='og:url' content=settings.siteUrl + currentUrl)
|
||||||
if settings.social && settings.social.facebook && settings.social.facebook.appId
|
if settings.social && settings.social.facebook && settings.social.facebook.appId
|
||||||
meta(property='fb:app_id' content=settings.social.facebook.appId)
|
meta(property='fb:app_id' content=settings.social.facebook.appId)
|
||||||
|
|
||||||
@@ -104,14 +107,14 @@ else if settings.overleaf
|
|||||||
content=buildImgPath('ol-brand/overleaf_og_logo.png')
|
content=buildImgPath('ol-brand/overleaf_og_logo.png')
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
//- the default image for Overleaf Community Edition/Server Pro
|
//- the default image for Verso Community Edition
|
||||||
meta(
|
meta(
|
||||||
property='og:image'
|
property='og:image'
|
||||||
content=buildBaseAssetPath() + 'apple-touch-icon.png'
|
content=buildBaseAssetPath() + 'og-image.png'
|
||||||
)
|
)
|
||||||
|
|
||||||
if metadata && metadata.openGraphType
|
if metadata && metadata.openGraphType
|
||||||
meta(property='og:type' metadata.openGraphType)
|
meta(property='og:type' content=metadata.openGraphType)
|
||||||
else
|
else
|
||||||
meta(property='og:type' content='website')
|
meta(property='og:type' content='website')
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ html(
|
|||||||
'red-nav-bar-for-admins': !settings.isDevEnv && hasFeature('saas') && hasAdminAccess(),
|
'red-nav-bar-for-admins': !settings.isDevEnv && hasFeature('saas') && hasAdminAccess(),
|
||||||
}
|
}
|
||||||
data-theme='light'
|
data-theme='light'
|
||||||
|
data-lumiere=isLumiere ? 'true' : 'false'
|
||||||
)
|
)
|
||||||
if settings.recaptcha && settings.recaptcha.siteKeyV3
|
if settings.recaptcha && settings.recaptcha.siteKeyV3
|
||||||
script(
|
script(
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ block append meta
|
|||||||
showPoweredBy: !hasFeature('saas') && !settings.nav.hide_powered_by,
|
showPoweredBy: !hasFeature('saas') && !settings.nav.hide_powered_by,
|
||||||
subdomainLang: settings.i18n.subdomainLang,
|
subdomainLang: settings.i18n.subdomainLang,
|
||||||
translatedLanguages: settings.translatedLanguages,
|
translatedLanguages: settings.translatedLanguages,
|
||||||
|
availableLanguages: availableLanguages,
|
||||||
leftItems: cloneAndTranslateText(settings.nav.left_footer),
|
leftItems: cloneAndTranslateText(settings.nav.left_footer),
|
||||||
rightItems: settings.nav.right_footer,
|
rightItems: settings.nav.right_footer,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,43 @@
|
|||||||
include ../_mixins/material_symbol
|
li.language-picker
|
||||||
|
details.language-picker-details
|
||||||
li.dropdown.dropup.subdued.language-picker(dropdown)
|
summary#language-picker-toggle.btn-inline-link(
|
||||||
button#language-picker-toggle.btn.btn-link.btn-inline-link(
|
aria-label=translate('select_a_language')
|
||||||
dropdown-toggle
|
translate='no'
|
||||||
data-ol-lang-selector-tooltip
|
)
|
||||||
data-bs-toggle='dropdown'
|
span.material-symbols translate
|
||||||
aria-haspopup='true'
|
|
|
||||||
aria-expanded='false'
|
span.language-picker-text= settings.translatedLanguages[currentLngCode] || currentLngCode
|
||||||
aria-label='Select ' + translate('language')
|
ul.dropdown-menu.dropdown-menu-sm-width(
|
||||||
tooltip=translate('language')
|
role='menu'
|
||||||
title=translate('language')
|
aria-labelledby='language-picker-toggle'
|
||||||
)
|
translate='no'
|
||||||
+material-symbol('translate')
|
)
|
||||||
|
|
each lngCode in availableLanguages
|
||||||
span.language-picker-text #{settings.translatedLanguages[currentLngCode]}
|
if settings.translatedLanguages[lngCode]
|
||||||
|
li(role='none')
|
||||||
ul.dropdown-menu.dropdown-menu-sm-width(
|
a.dropdown-item(
|
||||||
role='menu'
|
role='menuitem'
|
||||||
aria-labelledby='language-picker-toggle'
|
href='/set-language?lng=' + encodeURIComponent(lngCode) + '&return_to=/'
|
||||||
)
|
data-lng=lngCode
|
||||||
li.dropdown-header #{translate("language")}
|
class=lngCode === currentLngCode ? 'active' : ''
|
||||||
each subdomainDetails, subdomain in settings.i18n.subdomainLang
|
)= settings.translatedLanguages[lngCode]
|
||||||
if !subdomainDetails.hide
|
script.
|
||||||
- let isActive = subdomainDetails.lngCode === currentLngCode
|
(function () {
|
||||||
li.lng-option
|
var details = document.querySelector('.language-picker-details')
|
||||||
a.menu-indent(
|
var menu = details && details.querySelector('.dropdown-menu')
|
||||||
href=subdomainDetails.url + currentUrlWithQueryParams
|
if (!details || !menu) return
|
||||||
role='menuitem'
|
menu.querySelectorAll('a[data-lng]').forEach(function (a) {
|
||||||
class=['dropdown-item', {active: isActive}]
|
var lng = a.getAttribute('data-lng')
|
||||||
aria-selected=isActive ? 'true' : 'false'
|
a.href =
|
||||||
)
|
'/set-language?lng=' +
|
||||||
| #{settings.translatedLanguages[subdomainDetails.lngCode]}
|
encodeURIComponent(lng) +
|
||||||
if subdomainDetails.lngCode === currentLngCode
|
'&return_to=' +
|
||||||
+material-symbol('check', 'dropdown-item-trailing-icon')
|
encodeURIComponent(window.location.pathname)
|
||||||
|
})
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!details.contains(e.target)) details.open = false
|
||||||
|
})
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') details.open = false
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
footer.site-footer
|
footer.site-footer
|
||||||
- var showLanguagePicker = Object.keys(settings.i18n.subdomainLang).length > 1
|
- var showLanguagePicker = availableLanguages.length > 1
|
||||||
- var hasCustomLeftNav = nav.left_footer && nav.left_footer.length > 0
|
- var hasCustomLeftNav = nav.left_footer && nav.left_footer.length > 0
|
||||||
.site-footer-content.hidden-print
|
.site-footer-content.hidden-print
|
||||||
.row
|
.row
|
||||||
@@ -8,22 +8,22 @@ footer.site-footer
|
|||||||
| © #{new Date().getFullYear()}
|
| © #{new Date().getFullYear()}
|
||||||
|
|
|
|
||||||
a(href='https://alocoq.fr' target='_blank' rel='noopener noreferrer') Aloïs Coquillard
|
a(href='https://alocoq.fr' target='_blank' rel='noopener noreferrer') Aloïs Coquillard
|
||||||
li
|
li.footer-sep
|
||||||
strong.text-muted |
|
strong.text-muted |
|
||||||
li
|
li
|
||||||
| Built on
|
| !{translate('built_on')}
|
||||||
|
|
|
|
||||||
a(href='https://github.com/overleaf/overleaf' target='_blank' rel='noopener noreferrer') Overleaf
|
a(href='https://github.com/overleaf/overleaf' target='_blank' rel='noopener noreferrer') Overleaf
|
||||||
|
|
||||||
if showLanguagePicker || hasCustomLeftNav
|
if showLanguagePicker || hasCustomLeftNav
|
||||||
li
|
li.footer-sep
|
||||||
strong.text-muted |
|
strong.text-muted |
|
||||||
|
|
||||||
if showLanguagePicker
|
if showLanguagePicker
|
||||||
include language-picker
|
include language-picker
|
||||||
|
|
||||||
if showLanguagePicker && hasCustomLeftNav
|
if showLanguagePicker && hasCustomLeftNav
|
||||||
li
|
li.footer-sep
|
||||||
strong.text-muted |
|
strong.text-muted |
|
||||||
|
|
||||||
each item in nav.left_footer
|
each item in nav.left_footer
|
||||||
@@ -33,10 +33,10 @@ footer.site-footer
|
|||||||
else
|
else
|
||||||
| !{item.text}
|
| !{item.text}
|
||||||
|
|
||||||
ul.site-footer-items.col-lg-3.text-end
|
ul.site-footer-items.col-lg-3.text-lg-end
|
||||||
li
|
li
|
||||||
a(href='https://git.alocoq.fr/alois/verso/src/branch/main/LICENSE' target='_blank' rel='noopener noreferrer') AGPL licence
|
a(href='https://git.alocoq.fr/alois/verso/src/branch/main/LICENSE' target='_blank' rel='noopener noreferrer') AGPL licence
|
||||||
li
|
li.footer-sep
|
||||||
strong.text-muted |
|
strong.text-muted |
|
||||||
li
|
li
|
||||||
a(href='https://git.alocoq.fr/alois/verso' target='_blank' rel='noopener noreferrer') Source code
|
a(href='https://git.alocoq.fr/alois/verso' target='_blank' rel='noopener noreferrer') Source code
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ meta(name="ol-wikiEnabled" data-type="boolean" content=settings.proxyLearn)
|
|||||||
meta(name="ol-capabilities" data-type="json" content=capabilities)
|
meta(name="ol-capabilities" data-type="json" content=capabilities)
|
||||||
meta(name="ol-gitBridgePublicBaseUrl" content=gitBridgePublicBaseUrl)
|
meta(name="ol-gitBridgePublicBaseUrl" content=gitBridgePublicBaseUrl)
|
||||||
meta(name="ol-gitBridgeEnabled" data-type="boolean" content=gitBridgeEnabled)
|
meta(name="ol-gitBridgeEnabled" data-type="boolean" content=gitBridgeEnabled)
|
||||||
|
meta(name="ol-gitSyncEnabled" data-type="boolean" content=gitSyncEnabled)
|
||||||
|
meta(name="ol-gitRemote" content=gitRemote)
|
||||||
|
meta(name="ol-gitSyncPath" content=gitSyncPath)
|
||||||
|
meta(name="ol-gitSyncPdfPath" content=gitSyncPdfPath)
|
||||||
|
meta(name="ol-gitSyncPushFiles" data-type="boolean" content=gitSyncPushFiles)
|
||||||
|
meta(name="ol-gitSyncPushPdf" data-type="boolean" content=gitSyncPushPdf)
|
||||||
|
meta(name="ol-gitSyncBranch" content=gitSyncBranch)
|
||||||
meta(name="ol-compilesUserContentDomain" content=settings.compilesUserContentDomain)
|
meta(name="ol-compilesUserContentDomain" content=settings.compilesUserContentDomain)
|
||||||
//- enable doc hash checking for all projects
|
//- enable doc hash checking for all projects
|
||||||
//- used in public/js/libs/sharejs.js
|
//- used in public/js/libs/sharejs.js
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ block vars
|
|||||||
- var suppressNavbar = true
|
- var suppressNavbar = true
|
||||||
|
|
||||||
block content
|
block content
|
||||||
main#main-content.content
|
main#main-content.content.login-page
|
||||||
.container
|
.container
|
||||||
.row
|
.row
|
||||||
.col-12
|
.col-12
|
||||||
.text-center.mb-4
|
.lumiere-logo-center.mb-4
|
||||||
img.verso-login-logo(
|
img.verso-login-logo(
|
||||||
src=buildImgPath('ol-brand/verso-logo.svg')
|
src=buildImgPath('ol-brand/verso-logo.svg')
|
||||||
alt='Verso'
|
alt='Verso'
|
||||||
style='width:100%;max-width:480px;height:auto'
|
style='width:100%;height:auto'
|
||||||
)
|
)
|
||||||
.row
|
.row
|
||||||
.col-lg-6.offset-lg-3.col-xl-4.offset-xl-4
|
.login-lumiere-card.col-lg-6.offset-lg-3.col-xl-4.offset-xl-4
|
||||||
.page-header
|
.page-header
|
||||||
if login_support_title
|
if login_support_title
|
||||||
h1 !{login_support_title}
|
h1 !{login_support_title}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ block content
|
|||||||
|
|
||||||
main#main-content(data-ol-captcha-retry-trigger-area='')
|
main#main-content(data-ol-captcha-retry-trigger-area='')
|
||||||
a.auth-aux-logo(href='/')
|
a.auth-aux-logo(href='/')
|
||||||
img(src=buildImgPath('ol-brand/overleaf-o-dark.svg') alt=settings.appName)
|
img(src=buildImgPath('ol-brand/verso-square.svg') alt='Verso')
|
||||||
.auth-aux-container
|
.auth-aux-container
|
||||||
form(
|
form(
|
||||||
name='passwordResetForm'
|
name='passwordResetForm'
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ block vars
|
|||||||
block content
|
block content
|
||||||
main#main-content
|
main#main-content
|
||||||
.auth-aux-container
|
.auth-aux-container
|
||||||
img.w-50.d-block(
|
img.d-block(
|
||||||
src=buildImgPath('ol-brand/overleaf.svg')
|
src=buildImgPath('ol-brand/verso-logo.svg')
|
||||||
alt=settings.appName
|
alt='Verso'
|
||||||
|
style='height:36px;width:auto;margin-bottom:1.5rem'
|
||||||
)
|
)
|
||||||
h1.h3.mb-3 #{translate("keep_your_account_safe")}
|
h1.h3.mb-3 #{translate("keep_your_account_safe")}
|
||||||
div(data-ol-multi-submit)
|
div(data-ol-multi-submit)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ block vars
|
|||||||
block content
|
block content
|
||||||
main#main-content
|
main#main-content
|
||||||
a.auth-aux-logo(href='/')
|
a.auth-aux-logo(href='/')
|
||||||
img(src=buildImgPath('ol-brand/overleaf-o-dark.svg') alt=settings.appName)
|
img(src=buildImgPath('ol-brand/verso-square.svg') alt='Verso')
|
||||||
.auth-aux-container
|
.auth-aux-container
|
||||||
form(
|
form(
|
||||||
name='passwordResetForm'
|
name='passwordResetForm'
|
||||||
|
|||||||
@@ -77,8 +77,8 @@
|
|||||||
"add_another_address_line": "",
|
"add_another_address_line": "",
|
||||||
"add_another_email": "",
|
"add_another_email": "",
|
||||||
"add_another_token": "",
|
"add_another_token": "",
|
||||||
"add_comma_separated_emails_help": "",
|
|
||||||
"add_collaborators": "",
|
"add_collaborators": "",
|
||||||
|
"add_comma_separated_emails_help": "",
|
||||||
"add_comment": "",
|
"add_comment": "",
|
||||||
"add_comment_error_message": "",
|
"add_comment_error_message": "",
|
||||||
"add_comment_error_title": "",
|
"add_comment_error_title": "",
|
||||||
@@ -117,6 +117,7 @@
|
|||||||
"after_that_well_bill_you_x_total_y_subtotal_z_tax_annually_on_date_unless_you_cancel": "",
|
"after_that_well_bill_you_x_total_y_subtotal_z_tax_annually_on_date_unless_you_cancel": "",
|
||||||
"aggregate_changed": "",
|
"aggregate_changed": "",
|
||||||
"aggregate_to": "",
|
"aggregate_to": "",
|
||||||
|
"agpl_licence": "",
|
||||||
"agree": "",
|
"agree": "",
|
||||||
"agree_with_the_terms": "",
|
"agree_with_the_terms": "",
|
||||||
"ai_assist_in_overleaf_is_included_via_writefull_groups": "",
|
"ai_assist_in_overleaf_is_included_via_writefull_groups": "",
|
||||||
@@ -228,6 +229,7 @@
|
|||||||
"breadcrumbs": "",
|
"breadcrumbs": "",
|
||||||
"browser": "",
|
"browser": "",
|
||||||
"build_collection_of_most_used_references": "",
|
"build_collection_of_most_used_references": "",
|
||||||
|
"built_on": "",
|
||||||
"bullet_list": "",
|
"bullet_list": "",
|
||||||
"buy_licenses": "",
|
"buy_licenses": "",
|
||||||
"buy_more_licenses": "",
|
"buy_more_licenses": "",
|
||||||
@@ -385,6 +387,8 @@
|
|||||||
"continue_using_free_features": "",
|
"continue_using_free_features": "",
|
||||||
"continue_with_free_plan": "",
|
"continue_with_free_plan": "",
|
||||||
"conversion_error_details": "",
|
"conversion_error_details": "",
|
||||||
|
"convert_to_latex": "",
|
||||||
|
"convert_to_typst": "",
|
||||||
"cookie_banner": "",
|
"cookie_banner": "",
|
||||||
"cookie_banner_info": "",
|
"cookie_banner_info": "",
|
||||||
"copied": "",
|
"copied": "",
|
||||||
@@ -471,6 +475,8 @@
|
|||||||
"demonstrating_track_changes_feature": "",
|
"demonstrating_track_changes_feature": "",
|
||||||
"department": "",
|
"department": "",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
"card_size": "",
|
||||||
|
"deselect_all": "",
|
||||||
"details": "",
|
"details": "",
|
||||||
"details_provided_by_google_explanation": "",
|
"details_provided_by_google_explanation": "",
|
||||||
"dictionary": "",
|
"dictionary": "",
|
||||||
@@ -665,7 +671,9 @@
|
|||||||
"explore_plans": "",
|
"explore_plans": "",
|
||||||
"export_as_docx": "",
|
"export_as_docx": "",
|
||||||
"export_as_html": "",
|
"export_as_html": "",
|
||||||
|
"export_as_latex": "",
|
||||||
"export_as_markdown": "",
|
"export_as_markdown": "",
|
||||||
|
"export_as_typst": "",
|
||||||
"export_csv": "",
|
"export_csv": "",
|
||||||
"export_project_to_github": "",
|
"export_project_to_github": "",
|
||||||
"failed": "",
|
"failed": "",
|
||||||
@@ -786,6 +794,27 @@
|
|||||||
"git_bridge_modal_git_clone_your_project": "",
|
"git_bridge_modal_git_clone_your_project": "",
|
||||||
"git_bridge_modal_learn_more_about_authentication_tokens": "",
|
"git_bridge_modal_learn_more_about_authentication_tokens": "",
|
||||||
"git_bridge_modal_read_only": "",
|
"git_bridge_modal_read_only": "",
|
||||||
|
"git_sync": "",
|
||||||
|
"git_sync_description": "",
|
||||||
|
"git_sync_pull_now": "",
|
||||||
|
"git_sync_pull_success": "",
|
||||||
|
"git_sync_pulling": "",
|
||||||
|
"git_sync_push_now": "",
|
||||||
|
"git_sync_pushing": "",
|
||||||
|
"git_sync_push_success": "",
|
||||||
|
"git_sync_remote_url": "",
|
||||||
|
"git_sync_saved": "",
|
||||||
|
"git_sync_auto_push_on_compile": "",
|
||||||
|
"git_sync_branch": "",
|
||||||
|
"git_sync_confirm_reset": "",
|
||||||
|
"git_sync_confirm_push": "",
|
||||||
|
"git_sync_repo_root": "",
|
||||||
|
"git_sync_push_files": "",
|
||||||
|
"git_sync_push_pdf": "",
|
||||||
|
"git_sync_pdf_path": "",
|
||||||
|
"git_sync_pdf_path_placeholder": "",
|
||||||
|
"git_sync_sub_path": "",
|
||||||
|
"git_sync_sub_path_placeholder": "",
|
||||||
"git_bridge_modal_review_access": "",
|
"git_bridge_modal_review_access": "",
|
||||||
"git_bridge_modal_see_once": "",
|
"git_bridge_modal_see_once": "",
|
||||||
"git_bridge_modal_use_previous_token": "",
|
"git_bridge_modal_use_previous_token": "",
|
||||||
@@ -1010,6 +1039,7 @@
|
|||||||
"institution_templates": "",
|
"institution_templates": "",
|
||||||
"integrations": "",
|
"integrations": "",
|
||||||
"integrations_like_github": "",
|
"integrations_like_github": "",
|
||||||
|
"interface_language": "",
|
||||||
"interested_in_cheaper_personal_plan": "",
|
"interested_in_cheaper_personal_plan": "",
|
||||||
"invalid_confirmation_code": "",
|
"invalid_confirmation_code": "",
|
||||||
"invalid_email": "",
|
"invalid_email": "",
|
||||||
@@ -1093,6 +1123,7 @@
|
|||||||
"last_verified": "",
|
"last_verified": "",
|
||||||
"latam_discount_modal_info": "",
|
"latam_discount_modal_info": "",
|
||||||
"latam_discount_modal_title": "",
|
"latam_discount_modal_title": "",
|
||||||
|
"latex_export_feedback_message": "",
|
||||||
"latex_places_figures_according_to_a_special_algorithm": "",
|
"latex_places_figures_according_to_a_special_algorithm": "",
|
||||||
"latex_places_tables_according_to_a_special_algorithm": "",
|
"latex_places_tables_according_to_a_special_algorithm": "",
|
||||||
"layout_options": "",
|
"layout_options": "",
|
||||||
@@ -1266,6 +1297,8 @@
|
|||||||
"n_more_updates_above_plural": "",
|
"n_more_updates_above_plural": "",
|
||||||
"n_more_updates_below": "",
|
"n_more_updates_below": "",
|
||||||
"n_more_updates_below_plural": "",
|
"n_more_updates_below_plural": "",
|
||||||
|
"n_projects_selected": "",
|
||||||
|
"n_projects_selected_plural": "",
|
||||||
"name": "",
|
"name": "",
|
||||||
"name_usage_explanation": "",
|
"name_usage_explanation": "",
|
||||||
"navigation": "",
|
"navigation": "",
|
||||||
@@ -1466,8 +1499,6 @@
|
|||||||
"please_ask_the_project_owner_to_upgrade_to_track_changes": "",
|
"please_ask_the_project_owner_to_upgrade_to_track_changes": "",
|
||||||
"please_change_primary_to_remove": "",
|
"please_change_primary_to_remove": "",
|
||||||
"please_compile_pdf_before_download": "",
|
"please_compile_pdf_before_download": "",
|
||||||
"python_packages": "",
|
|
||||||
"python_packages_help": "",
|
|
||||||
"please_confirm_primary_email_or_edit": "",
|
"please_confirm_primary_email_or_edit": "",
|
||||||
"please_confirm_secondary_email_or_edit": "",
|
"please_confirm_secondary_email_or_edit": "",
|
||||||
"please_confirm_your_email_before_making_it_default": "",
|
"please_confirm_your_email_before_making_it_default": "",
|
||||||
@@ -1502,14 +1533,14 @@
|
|||||||
"premium_plan_label": "",
|
"premium_plan_label": "",
|
||||||
"preparing_for_export": "",
|
"preparing_for_export": "",
|
||||||
"preparing_your_download": "",
|
"preparing_your_download": "",
|
||||||
|
"present": "",
|
||||||
|
"present_publishes_and_opens_in_new_tab": "",
|
||||||
"presentation_export_can_take_a_moment": "",
|
"presentation_export_can_take_a_moment": "",
|
||||||
"presentation_export_failed": "",
|
"presentation_export_failed": "",
|
||||||
"presentation_link_members": "",
|
"presentation_link_members": "",
|
||||||
"presentation_link_private": "",
|
"presentation_link_private": "",
|
||||||
"presentation_link_public": "",
|
"presentation_link_public": "",
|
||||||
"presentation_mode": "",
|
"presentation_mode": "",
|
||||||
"present": "",
|
|
||||||
"present_publishes_and_opens_in_new_tab": "",
|
|
||||||
"press_shift_space_for_suggestions": "",
|
"press_shift_space_for_suggestions": "",
|
||||||
"press_space_to_open_the_ai_assistant": "",
|
"press_space_to_open_the_ai_assistant": "",
|
||||||
"preview": "",
|
"preview": "",
|
||||||
@@ -1582,6 +1613,8 @@
|
|||||||
"pull_github_changes_into_sharelatex": "",
|
"pull_github_changes_into_sharelatex": "",
|
||||||
"push_sharelatex_changes_to_github": "",
|
"push_sharelatex_changes_to_github": "",
|
||||||
"push_to_github_pull_to_overleaf": "",
|
"push_to_github_pull_to_overleaf": "",
|
||||||
|
"python_packages": "",
|
||||||
|
"python_packages_help": "",
|
||||||
"quoted_text": "",
|
"quoted_text": "",
|
||||||
"raw_logs": "",
|
"raw_logs": "",
|
||||||
"raw_logs_description": "",
|
"raw_logs_description": "",
|
||||||
@@ -1906,6 +1939,7 @@
|
|||||||
"sort_by": "",
|
"sort_by": "",
|
||||||
"sort_by_x": "",
|
"sort_by_x": "",
|
||||||
"sort_projects": "",
|
"sort_projects": "",
|
||||||
|
"source_code": "",
|
||||||
"speak": "",
|
"speak": "",
|
||||||
"speech_input_not_available": "",
|
"speech_input_not_available": "",
|
||||||
"spellcheck": "",
|
"spellcheck": "",
|
||||||
@@ -2214,6 +2248,7 @@
|
|||||||
"tooltip_hide_pdf": "",
|
"tooltip_hide_pdf": "",
|
||||||
"tooltip_show_panel": "",
|
"tooltip_show_panel": "",
|
||||||
"tooltip_show_pdf": "",
|
"tooltip_show_pdf": "",
|
||||||
|
"top_bottom_split_view": "",
|
||||||
"total_due_in_x_days": "",
|
"total_due_in_x_days": "",
|
||||||
"total_due_today": "",
|
"total_due_today": "",
|
||||||
"total_per_month": "",
|
"total_per_month": "",
|
||||||
@@ -2250,6 +2285,13 @@
|
|||||||
"turn_off_link_sharing": "",
|
"turn_off_link_sharing": "",
|
||||||
"turn_on": "",
|
"turn_on": "",
|
||||||
"turn_on_link_sharing": "",
|
"turn_on_link_sharing": "",
|
||||||
|
"typst_export_feedback_message": "",
|
||||||
|
"typst_preview_mode": "",
|
||||||
|
"typst_preview_pdf": "",
|
||||||
|
"typst_preview_wasm": "",
|
||||||
|
"experimental": "",
|
||||||
|
"typst_wasm_error": "",
|
||||||
|
"typst_wasm_loading": "",
|
||||||
"unarchive": "",
|
"unarchive": "",
|
||||||
"uncategorized": "",
|
"uncategorized": "",
|
||||||
"uncategorized_projects": "",
|
"uncategorized_projects": "",
|
||||||
|
|||||||
+18
@@ -173,6 +173,24 @@ export default function FileTreeUploadDoc() {
|
|||||||
// limit: maxConnections || 1,
|
// limit: maxConnections || 1,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
fieldName: 'qqfile', // "qqfile" field inherited from FineUploader
|
fieldName: 'qqfile', // "qqfile" field inherited from FineUploader
|
||||||
|
// The server sends HTTP 200 + a keepalive '\n' byte immediately to
|
||||||
|
// prevent upstream proxy timeouts on slow connections, then streams
|
||||||
|
// the actual JSON result as the final chunk. Trim before parsing.
|
||||||
|
getResponseData: (responseText: string) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(responseText.trim())
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
validateStatus: (statusCode: number, responseText: string) => {
|
||||||
|
if (statusCode < 200 || statusCode >= 300) return false
|
||||||
|
try {
|
||||||
|
return JSON.parse(responseText.trim()).success === true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
// close the modal when all the uploads completed successfully
|
// close the modal when all the uploads completed successfully
|
||||||
.on('complete', result => {
|
.on('complete', result => {
|
||||||
|
|||||||
+3
-1
@@ -30,7 +30,8 @@ function FileTreeItemInner({
|
|||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { fileTreeReadOnly } = useFileTreeData()
|
const { fileTreeReadOnly } = useFileTreeData()
|
||||||
const { setContextMenuCoords } = useFileTreeMainContext()
|
const { setContextMenuCoords, setContextMenuEntityId } =
|
||||||
|
useFileTreeMainContext()
|
||||||
const { isRenaming } = useFileTreeActionable()
|
const { isRenaming } = useFileTreeActionable()
|
||||||
const { selectedEntityIds } = useFileTreeSelectable()
|
const { selectedEntityIds } = useFileTreeSelectable()
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ function FileTreeItemInner({
|
|||||||
|
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
|
|
||||||
|
setContextMenuEntityId(id)
|
||||||
setContextMenuCoords({
|
setContextMenuCoords({
|
||||||
top: ev.pageY,
|
top: ev.pageY,
|
||||||
left: ev.pageX,
|
left: ev.pageX,
|
||||||
|
|||||||
+93
-5
@@ -2,12 +2,31 @@ import { useCallback } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import * as eventTracking from '../../../../infrastructure/event-tracking'
|
import * as eventTracking from '../../../../infrastructure/event-tracking'
|
||||||
import { useProjectContext } from '@/shared/context/project-context'
|
import { useProjectContext } from '@/shared/context/project-context'
|
||||||
|
import { postJSON } from '@/infrastructure/fetch-json'
|
||||||
|
import type { ProjectCompiler } from '@ol-types/project-settings'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownDivider,
|
DropdownDivider,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
} from '@/shared/components/dropdown/dropdown-menu'
|
} from '@/shared/components/dropdown/dropdown-menu'
|
||||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||||
|
import { useFileTreeSelectable } from '../../contexts/file-tree-selectable'
|
||||||
|
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||||
|
import { useFileTreeMainContext } from '../../contexts/file-tree-main'
|
||||||
|
import { findInTree } from '../../util/find-in-tree'
|
||||||
|
import useConvertDoc from '@/features/ide-react/hooks/use-convert-doc'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
import { isValidTeXFile } from '@/main/is-valid-tex-file'
|
||||||
|
|
||||||
|
const COMPILER_BY_EXT: Record<string, ProjectCompiler> = {
|
||||||
|
tex: 'pdflatex',
|
||||||
|
rtex: 'pdflatex',
|
||||||
|
ltx: 'pdflatex',
|
||||||
|
rnw: 'pdflatex',
|
||||||
|
typ: 'typst',
|
||||||
|
qmd: 'quarto',
|
||||||
|
rmd: 'quarto',
|
||||||
|
}
|
||||||
|
|
||||||
function FileTreeItemMenuItems() {
|
function FileTreeItemMenuItems() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -23,12 +42,62 @@ function FileTreeItemMenuItems() {
|
|||||||
startUploadingDocOrFile,
|
startUploadingDocOrFile,
|
||||||
downloadPath,
|
downloadPath,
|
||||||
selectedFileName,
|
selectedFileName,
|
||||||
canSetRootDocId,
|
|
||||||
setRootDocId,
|
|
||||||
} = useFileTreeActionable()
|
} = useFileTreeActionable()
|
||||||
|
|
||||||
const { project } = useProjectContext()
|
const { project, projectId, updateProject } = useProjectContext()
|
||||||
const projectOwner = project?.owner?._id
|
const projectOwner = project?.owner?._id
|
||||||
|
const rootDocId = project?.rootDocId
|
||||||
|
|
||||||
|
const { fileTreeData, fileTreeReadOnly } = useFileTreeData()
|
||||||
|
const { selectedEntityIds } = useFileTreeSelectable()
|
||||||
|
const { contextMenuEntityId } = useFileTreeMainContext()
|
||||||
|
const selectedEntityId =
|
||||||
|
selectedEntityIds.size === 1 ? Array.from(selectedEntityIds)[0] : null
|
||||||
|
|
||||||
|
// Use context-menu-target entity for convert/set-as-main; falls back to selection
|
||||||
|
const convertEntityId = contextMenuEntityId ?? selectedEntityId
|
||||||
|
const convertEntity = convertEntityId
|
||||||
|
? findInTree(fileTreeData, convertEntityId)
|
||||||
|
: null
|
||||||
|
const isConvertableDoc = convertEntity?.type === 'doc'
|
||||||
|
const convertEntityName = convertEntity?.entity.name ?? null
|
||||||
|
|
||||||
|
const enablePandocConversions =
|
||||||
|
getMeta('ol-ExposedSettings')?.enablePandocConversions
|
||||||
|
|
||||||
|
const canConvertToTypst =
|
||||||
|
enablePandocConversions &&
|
||||||
|
!fileTreeReadOnly &&
|
||||||
|
isConvertableDoc &&
|
||||||
|
convertEntityName?.endsWith('.tex')
|
||||||
|
const canConvertToLatex =
|
||||||
|
enablePandocConversions &&
|
||||||
|
!fileTreeReadOnly &&
|
||||||
|
isConvertableDoc &&
|
||||||
|
convertEntityName?.endsWith('.typ')
|
||||||
|
|
||||||
|
const canShowSetAsMain =
|
||||||
|
!fileTreeReadOnly &&
|
||||||
|
isConvertableDoc &&
|
||||||
|
!!convertEntityId &&
|
||||||
|
convertEntityId !== rootDocId &&
|
||||||
|
!!convertEntityName &&
|
||||||
|
isValidTeXFile(convertEntityName)
|
||||||
|
|
||||||
|
const handleSetAsMain = useCallback(async () => {
|
||||||
|
if (!convertEntityId || !convertEntityName) return
|
||||||
|
const ext = convertEntityName.split('.').pop()?.toLowerCase() ?? ''
|
||||||
|
const newCompiler = COMPILER_BY_EXT[ext]
|
||||||
|
const body: Record<string, string> = { rootDocId: convertEntityId }
|
||||||
|
if (newCompiler && newCompiler !== project?.compiler) body.compiler = newCompiler
|
||||||
|
await postJSON(`/project/${projectId}/settings`, { body })
|
||||||
|
const update: Record<string, string> = { rootDocId: convertEntityId }
|
||||||
|
if (newCompiler) update.compiler = newCompiler
|
||||||
|
updateProject(update)
|
||||||
|
}, [convertEntityId, convertEntityName, project, projectId, updateProject])
|
||||||
|
|
||||||
|
const { convert: convertToTypst } = useConvertDoc('typst', convertEntityId)
|
||||||
|
const { convert: convertToLatex } = useConvertDoc('latex', convertEntityId)
|
||||||
|
|
||||||
const downloadWithAnalytics = useCallback(() => {
|
const downloadWithAnalytics = useCallback(() => {
|
||||||
// we are only interested in downloads of bib files WRT analytics, for the purposes of promoting the tpr integrations
|
// we are only interested in downloads of bib files WRT analytics, for the purposes of promoting the tpr integrations
|
||||||
@@ -65,16 +134,35 @@ function FileTreeItemMenuItems() {
|
|||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</li>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
{canSetRootDocId ? (
|
{canShowSetAsMain ? (
|
||||||
<>
|
<>
|
||||||
<DropdownDivider />
|
<DropdownDivider />
|
||||||
<li role="none">
|
<li role="none">
|
||||||
<DropdownItem onClick={setRootDocId}>
|
<DropdownItem onClick={handleSetAsMain}>
|
||||||
{t('set_as_main_document')}
|
{t('set_as_main_document')}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</li>
|
</li>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{(canConvertToTypst || canConvertToLatex) ? (
|
||||||
|
<>
|
||||||
|
<DropdownDivider />
|
||||||
|
{canConvertToTypst && (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem onClick={convertToTypst}>
|
||||||
|
{t('convert_to_typst')}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{canConvertToLatex && (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem onClick={convertToLatex}>
|
||||||
|
{t('convert_to_latex')}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{canDelete ? (
|
{canDelete ? (
|
||||||
<>
|
<>
|
||||||
<DropdownDivider />
|
<DropdownDivider />
|
||||||
|
|||||||
+3
-1
@@ -5,7 +5,8 @@ import MaterialIcon from '@/shared/components/material-icon'
|
|||||||
|
|
||||||
function FileTreeItemMenu({ id, name }: { id: string; name: string }) {
|
function FileTreeItemMenu({ id, name }: { id: string; name: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
|
const { contextMenuCoords, setContextMenuCoords, setContextMenuEntityId } =
|
||||||
|
useFileTreeMainContext()
|
||||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
const isMenuOpen = Boolean(contextMenuCoords)
|
const isMenuOpen = Boolean(contextMenuCoords)
|
||||||
@@ -13,6 +14,7 @@ function FileTreeItemMenu({ id, name }: { id: string; name: string }) {
|
|||||||
function handleClick(event: React.MouseEvent) {
|
function handleClick(event: React.MouseEvent) {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!contextMenuCoords && menuButtonRef.current) {
|
if (!contextMenuCoords && menuButtonRef.current) {
|
||||||
|
setContextMenuEntityId(id)
|
||||||
const target = menuButtonRef.current.getBoundingClientRect()
|
const target = menuButtonRef.current.getBoundingClientRect()
|
||||||
setContextMenuCoords({
|
setContextMenuCoords({
|
||||||
top: target.top + target.height / 2,
|
top: target.top + target.height / 2,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const FileTreeMainContext = createContext<
|
|||||||
setStartedFreeTrial: (value: boolean) => void
|
setStartedFreeTrial: (value: boolean) => void
|
||||||
contextMenuCoords: ContextMenuCoords | null
|
contextMenuCoords: ContextMenuCoords | null
|
||||||
setContextMenuCoords: (value: ContextMenuCoords | null) => void
|
setContextMenuCoords: (value: ContextMenuCoords | null) => void
|
||||||
|
contextMenuEntityId: string | null
|
||||||
|
setContextMenuEntityId: (value: string | null) => void
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
>(undefined)
|
>(undefined)
|
||||||
@@ -39,6 +41,9 @@ export const FileTreeMainProvider: FC<
|
|||||||
}) => {
|
}) => {
|
||||||
const [contextMenuCoords, setContextMenuCoords] =
|
const [contextMenuCoords, setContextMenuCoords] =
|
||||||
useState<ContextMenuCoords | null>(null)
|
useState<ContextMenuCoords | null>(null)
|
||||||
|
const [contextMenuEntityId, setContextMenuEntityId] = useState<string | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileTreeMainContext.Provider
|
<FileTreeMainContext.Provider
|
||||||
@@ -48,6 +53,8 @@ export const FileTreeMainProvider: FC<
|
|||||||
setStartedFreeTrial,
|
setStartedFreeTrial,
|
||||||
contextMenuCoords,
|
contextMenuCoords,
|
||||||
setContextMenuCoords,
|
setContextMenuCoords,
|
||||||
|
contextMenuEntityId,
|
||||||
|
setContextMenuEntityId,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Panel, PanelGroup } from 'react-resizable-panels'
|
import { Panel, PanelGroup } from 'react-resizable-panels'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
|
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
|
||||||
|
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
|
||||||
import PdfPreview from '@/features/pdf-preview/components/pdf-preview'
|
import PdfPreview from '@/features/pdf-preview/components/pdf-preview'
|
||||||
import { RailLayout } from '../rail/rail'
|
import { RailLayout } from '../rail/rail'
|
||||||
import { Toolbar } from '../toolbar/toolbar'
|
import { Toolbar } from '../toolbar/toolbar'
|
||||||
@@ -8,7 +9,7 @@ import { HorizontalToggler } from '@/features/ide-react/components/resize/horizo
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { usePdfPane } from '@/features/ide-react/hooks/use-pdf-pane'
|
import { usePdfPane } from '@/features/ide-react/hooks/use-pdf-pane'
|
||||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||||
import { ElementType, useState } from 'react'
|
import { ElementType, useEffect, useState } from 'react'
|
||||||
import EditorPanel from '../editor/editor-panel'
|
import EditorPanel from '../editor/editor-panel'
|
||||||
import { useRailContext } from '../../context/rail-context'
|
import { useRailContext } from '../../context/rail-context'
|
||||||
import HistoryContainer from '@/features/ide-react/components/history-container'
|
import HistoryContainer from '@/features/ide-react/components/history-container'
|
||||||
@@ -25,6 +26,19 @@ const mainEditorLayoutModalsModules: Array<{
|
|||||||
path: string
|
path: string
|
||||||
}> = importOverleafModules('mainEditorLayoutModals')
|
}> = importOverleafModules('mainEditorLayoutModals')
|
||||||
|
|
||||||
|
// Bootstrap md breakpoint — below this we stack panels vertically on mobile
|
||||||
|
const MOBILE_MQ = '(max-width: 767px)'
|
||||||
|
// Secondary check: browsers that spoof viewport width (e.g. Tor Browser) still
|
||||||
|
// expose `pointer: coarse` for real touch hardware.
|
||||||
|
const TOUCH_MQ = '(pointer: coarse) and (max-width: 1024px)'
|
||||||
|
|
||||||
|
function detectMobile() {
|
||||||
|
return (
|
||||||
|
window.matchMedia(MOBILE_MQ).matches ||
|
||||||
|
window.matchMedia(TOUCH_MQ).matches
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
const [resizing, setResizing] = useState(false)
|
const [resizing, setResizing] = useState(false)
|
||||||
const { resizing: railResizing } = useRailContext()
|
const { resizing: railResizing } = useRailContext()
|
||||||
@@ -38,8 +52,29 @@ export default function MainLayout() {
|
|||||||
} = usePdfPane()
|
} = usePdfPane()
|
||||||
const { view, pdfLayout } = useLayoutContext()
|
const { view, pdfLayout } = useLayoutContext()
|
||||||
|
|
||||||
|
const [isMobile, setIsMobile] = useState(detectMobile)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setIsMobile(detectMobile())
|
||||||
|
const mq1 = window.matchMedia(MOBILE_MQ)
|
||||||
|
const mq2 = window.matchMedia(TOUCH_MQ)
|
||||||
|
mq1.addEventListener('change', handler)
|
||||||
|
mq2.addEventListener('change', handler)
|
||||||
|
return () => {
|
||||||
|
mq1.removeEventListener('change', handler)
|
||||||
|
mq2.removeEventListener('change', handler)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// verticalSplit is always vertical; sideBySide becomes vertical on mobile
|
||||||
|
const isVertical =
|
||||||
|
pdfLayout === 'verticalSplit' ||
|
||||||
|
(pdfLayout === 'sideBySide' && isMobile)
|
||||||
|
|
||||||
const editorIsOpen =
|
const editorIsOpen =
|
||||||
view === 'editor' || view === 'file' || pdfLayout === 'sideBySide'
|
view === 'editor' ||
|
||||||
|
view === 'file' ||
|
||||||
|
pdfLayout === 'sideBySide' ||
|
||||||
|
pdfLayout === 'verticalSplit'
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -58,8 +93,20 @@ export default function MainLayout() {
|
|||||||
<Panel id="ide-redesign-editor-and-pdf-panel" order={2}>
|
<Panel id="ide-redesign-editor-and-pdf-panel" order={2}>
|
||||||
<HistoryContainer />
|
<HistoryContainer />
|
||||||
<PanelGroup
|
<PanelGroup
|
||||||
autoSaveId="ide-redesign-editor-and-pdf-panel-group"
|
key={isVertical ? 'vertical' : 'horizontal'}
|
||||||
direction="horizontal"
|
autoSaveId={
|
||||||
|
isVertical
|
||||||
|
? // On mobile, skip autoSave: a stale collapsed PDF pane
|
||||||
|
// would fire onCollapse → changeLayout('flat'), overriding
|
||||||
|
// the verticalSplit default from getInitialLayout().
|
||||||
|
// The pdf.layout localStorage key already persists the
|
||||||
|
// user's explicit flat/open preference independently.
|
||||||
|
isMobile
|
||||||
|
? null
|
||||||
|
: 'ide-redesign-editor-and-pdf-panel-group-vertical'
|
||||||
|
: 'ide-redesign-editor-and-pdf-panel-group'
|
||||||
|
}
|
||||||
|
direction={isVertical ? 'vertical' : 'horizontal'}
|
||||||
className={classNames({
|
className={classNames({
|
||||||
hidden: view === 'history',
|
hidden: view === 'history',
|
||||||
})}
|
})}
|
||||||
@@ -79,29 +126,38 @@ export default function MainLayout() {
|
|||||||
<EditorPanel />
|
<EditorPanel />
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
<HorizontalResizeHandle
|
{isVertical ? (
|
||||||
resizable={pdfLayout === 'sideBySide'}
|
<VerticalResizeHandle
|
||||||
onDragging={setResizing}
|
onDragging={setResizing}
|
||||||
onDoubleClick={togglePdfPane}
|
className={classNames({
|
||||||
hitAreaMargins={{ coarse: 0, fine: 0 }}
|
hidden: !editorIsOpen,
|
||||||
className={classNames({
|
})}
|
||||||
hidden: !editorIsOpen,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<HorizontalToggler
|
|
||||||
id="ide-redesign-pdf-panel"
|
|
||||||
togglerType="east"
|
|
||||||
isOpen={isPdfOpen}
|
|
||||||
setIsOpen={setIsPdfOpen}
|
|
||||||
tooltipWhenOpen={t('tooltip_hide_pdf')}
|
|
||||||
tooltipWhenClosed={t('tooltip_show_pdf')}
|
|
||||||
/>
|
/>
|
||||||
{pdfLayout === 'sideBySide' && (
|
) : (
|
||||||
<div className="synctex-controls">
|
<HorizontalResizeHandle
|
||||||
<DefaultSynctexControl />
|
resizable={pdfLayout === 'sideBySide'}
|
||||||
</div>
|
onDragging={setResizing}
|
||||||
)}
|
onDoubleClick={togglePdfPane}
|
||||||
</HorizontalResizeHandle>
|
hitAreaMargins={{ coarse: 0, fine: 0 }}
|
||||||
|
className={classNames({
|
||||||
|
hidden: !editorIsOpen,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<HorizontalToggler
|
||||||
|
id="ide-redesign-pdf-panel"
|
||||||
|
togglerType="east"
|
||||||
|
isOpen={isPdfOpen}
|
||||||
|
setIsOpen={setIsPdfOpen}
|
||||||
|
tooltipWhenOpen={t('tooltip_hide_pdf')}
|
||||||
|
tooltipWhenClosed={t('tooltip_show_pdf')}
|
||||||
|
/>
|
||||||
|
{pdfLayout === 'sideBySide' && (
|
||||||
|
<div className="synctex-controls">
|
||||||
|
<DefaultSynctexControl />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</HorizontalResizeHandle>
|
||||||
|
)}
|
||||||
<Panel
|
<Panel
|
||||||
collapsible
|
collapsible
|
||||||
className={classNames('ide-redesign-pdf-container', {
|
className={classNames('ide-redesign-pdf-container', {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const RailLayout = () => {
|
|||||||
const { features } = useProjectContext()
|
const { features } = useProjectContext()
|
||||||
const { isRestrictedTokenMember } = useEditorContext()
|
const { isRestrictedTokenMember } = useEditorContext()
|
||||||
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
|
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
|
||||||
|
const gitSyncEnabled = getMeta('ol-gitSyncEnabled')
|
||||||
const { isOverleaf } = getMeta('ol-ExposedSettings')
|
const { isOverleaf } = getMeta('ol-ExposedSettings')
|
||||||
|
|
||||||
const { view, setLeftMenuShown } = useLayoutContext()
|
const { view, setLeftMenuShown } = useLayoutContext()
|
||||||
@@ -116,10 +117,10 @@ export const RailLayout = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'integrations',
|
key: 'integrations',
|
||||||
icon: 'integration_instructions',
|
icon: 'autorenew',
|
||||||
title: t('integrations'),
|
title: t('integrations'),
|
||||||
component: <IntegrationsPanel />,
|
component: <IntegrationsPanel />,
|
||||||
hide: !isOverleaf && !gitBridgeEnabled,
|
hide: !isOverleaf && !gitBridgeEnabled && !gitSyncEnabled,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'review-panel',
|
key: 'review-panel',
|
||||||
@@ -148,6 +149,7 @@ export const RailLayout = () => {
|
|||||||
isRestrictedTokenMember,
|
isRestrictedTokenMember,
|
||||||
isOverleaf,
|
isOverleaf,
|
||||||
gitBridgeEnabled,
|
gitBridgeEnabled,
|
||||||
|
gitSyncEnabled,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+24
-1
@@ -15,7 +15,12 @@ import { isMac } from '@/shared/utils/os'
|
|||||||
import { Shortcut } from '@/shared/components/shortcut'
|
import { Shortcut } from '@/shared/components/shortcut'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
|
||||||
type LayoutOption = 'sideBySide' | 'editorOnly' | 'pdfOnly' | 'detachedPdf'
|
type LayoutOption =
|
||||||
|
| 'sideBySide'
|
||||||
|
| 'verticalSplit'
|
||||||
|
| 'editorOnly'
|
||||||
|
| 'pdfOnly'
|
||||||
|
| 'detachedPdf'
|
||||||
|
|
||||||
const getActiveLayoutOption = ({
|
const getActiveLayoutOption = ({
|
||||||
pdfLayout,
|
pdfLayout,
|
||||||
@@ -46,6 +51,10 @@ const getActiveLayoutOption = ({
|
|||||||
return 'sideBySide'
|
return 'sideBySide'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pdfLayout === 'verticalSplit') {
|
||||||
|
return 'verticalSplit'
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,12 +101,14 @@ const shortcuts: Record<LayoutOption, string[] | null> = isMac
|
|||||||
editorOnly: ['⌃', '⌘', '←'],
|
editorOnly: ['⌃', '⌘', '←'],
|
||||||
pdfOnly: ['⌃', '⌘', '→'],
|
pdfOnly: ['⌃', '⌘', '→'],
|
||||||
sideBySide: ['⌃', '⌘', '↓'],
|
sideBySide: ['⌃', '⌘', '↓'],
|
||||||
|
verticalSplit: null,
|
||||||
detachedPdf: ['⌃', '⌘', '↑'],
|
detachedPdf: ['⌃', '⌘', '↑'],
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
editorOnly: null,
|
editorOnly: null,
|
||||||
pdfOnly: null,
|
pdfOnly: null,
|
||||||
sideBySide: null,
|
sideBySide: null,
|
||||||
|
verticalSplit: null,
|
||||||
detachedPdf: null,
|
detachedPdf: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +147,18 @@ export default function ChangeLayoutOptions() {
|
|||||||
>
|
>
|
||||||
{t('split_view')}
|
{t('split_view')}
|
||||||
</LayoutDropdownItem>
|
</LayoutDropdownItem>
|
||||||
|
<LayoutDropdownItem
|
||||||
|
onClick={() => handleChangeLayout('verticalSplit')}
|
||||||
|
active={activeLayoutOption === 'verticalSplit'}
|
||||||
|
leadingIcon="horizontal_split"
|
||||||
|
trailingIcon={
|
||||||
|
shortcuts.verticalSplit && (
|
||||||
|
<Shortcut keys={shortcuts.verticalSplit} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('top_bottom_split_view')}
|
||||||
|
</LayoutDropdownItem>
|
||||||
<LayoutDropdownItem
|
<LayoutDropdownItem
|
||||||
onClick={() => handleChangeLayout('flat', 'editor')}
|
onClick={() => handleChangeLayout('flat', 'editor')}
|
||||||
active={activeLayoutOption === 'editorOnly'}
|
active={activeLayoutOption === 'editorOnly'}
|
||||||
|
|||||||
+6
-1
@@ -42,6 +42,7 @@ const ExportDocumentErrorToast = ({ data }: { data?: any }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ExportDocumentSuccessToast = ({ data }: { data?: any }) => {
|
const ExportDocumentSuccessToast = ({ data }: { data?: any }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const type = data?.type
|
const type = data?.type
|
||||||
if (type === 'docx') {
|
if (type === 'docx') {
|
||||||
return (
|
return (
|
||||||
@@ -85,6 +86,10 @@ const ExportDocumentSuccessToast = ({ data }: { data?: any }) => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
} else if (type === 'typst') {
|
||||||
|
return <span>{t('typst_export_feedback_message')}</span>
|
||||||
|
} else if (type === 'latex') {
|
||||||
|
return <span>{t('latex_export_feedback_message')}</span>
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<Trans
|
<Trans
|
||||||
@@ -175,7 +180,7 @@ export const hidePreparingExportToast = (handle: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const showExportDocumentSuccess = (
|
export const showExportDocumentSuccess = (
|
||||||
type: 'docx' | 'markdown' | 'html'
|
type: 'docx' | 'markdown' | 'html' | 'typst' | 'latex'
|
||||||
) => {
|
) => {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('ide:show-toast', {
|
new CustomEvent('ide:show-toast', {
|
||||||
|
|||||||
+11
-2
@@ -6,10 +6,11 @@ import { useCommandProvider } from '../../hooks/use-command-provider'
|
|||||||
import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
|
import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
|
||||||
import { useRootDoc } from '@/shared/hooks/use-root-doc'
|
import { useRootDoc } from '@/shared/hooks/use-root-doc'
|
||||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||||
|
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
|
||||||
|
|
||||||
type ExportProjectWithConversionProps = {
|
type ExportProjectWithConversionProps = {
|
||||||
featureFlag?: string
|
featureFlag?: string
|
||||||
conversionType: 'docx' | 'markdown' | 'html'
|
conversionType: 'docx' | 'markdown' | 'html' | 'typst' | 'latex'
|
||||||
label: string
|
label: string
|
||||||
menuBarId: string
|
menuBarId: string
|
||||||
}
|
}
|
||||||
@@ -22,6 +23,11 @@ export const ExportProjectWithConversionButton: FC<
|
|||||||
const enablePandocConversions =
|
const enablePandocConversions =
|
||||||
getMeta('ol-ExposedSettings')?.enablePandocConversions
|
getMeta('ol-ExposedSettings')?.enablePandocConversions
|
||||||
const anonymous = getMeta('ol-anonymous')
|
const anonymous = getMeta('ol-anonymous')
|
||||||
|
const { compiler } = useProjectSettingsContext()
|
||||||
|
const isLatexProject = !['typst', 'quarto'].includes(compiler ?? '')
|
||||||
|
// latex export is for converting Typst projects to LaTeX; all others are for LaTeX projects
|
||||||
|
const isProjectTypeMatch =
|
||||||
|
conversionType === 'latex' ? compiler === 'typst' : isLatexProject
|
||||||
const getRootDocInfo = useRootDoc()
|
const getRootDocInfo = useRootDoc()
|
||||||
const { openDocs } = useEditorManagerContext()
|
const { openDocs } = useEditorManagerContext()
|
||||||
const downloadConversion = useConvertProject(
|
const downloadConversion = useConvertProject(
|
||||||
@@ -31,7 +37,10 @@ export const ExportProjectWithConversionButton: FC<
|
|||||||
)
|
)
|
||||||
|
|
||||||
const showExportButton =
|
const showExportButton =
|
||||||
splitTestEnabledIfNeeded && enablePandocConversions && !anonymous
|
splitTestEnabledIfNeeded &&
|
||||||
|
enablePandocConversions &&
|
||||||
|
!anonymous &&
|
||||||
|
isProjectTypeMatch
|
||||||
|
|
||||||
useCommandProvider(
|
useCommandProvider(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ export const ToolbarMenuBar = () => {
|
|||||||
'export-as-docx',
|
'export-as-docx',
|
||||||
'export-as-markdown',
|
'export-as-markdown',
|
||||||
'export-as-html',
|
'export-as-html',
|
||||||
|
'export-as-typst',
|
||||||
|
'export-as-latex',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -243,7 +245,7 @@ export const ToolbarMenuBar = () => {
|
|||||||
>
|
>
|
||||||
<ChangeLayoutOptions />
|
<ChangeLayoutOptions />
|
||||||
<DropdownDivider />
|
<DropdownDivider />
|
||||||
<DropdownHeader>Editor settings</DropdownHeader>
|
<DropdownHeader>{t('editor_settings')}</DropdownHeader>
|
||||||
<MenuBarOption
|
<MenuBarOption
|
||||||
eventKey="show_breadcrumbs"
|
eventKey="show_breadcrumbs"
|
||||||
title={t('show_breadcrumbs')}
|
title={t('show_breadcrumbs')}
|
||||||
|
|||||||
@@ -95,6 +95,16 @@ export const ToolbarProjectTitle = () => {
|
|||||||
label={t('export_as_html')}
|
label={t('export_as_html')}
|
||||||
menuBarId="export-as-html"
|
menuBarId="export-as-html"
|
||||||
/>
|
/>
|
||||||
|
<ExportProjectWithConversionButton
|
||||||
|
conversionType="typst"
|
||||||
|
label={t('export_as_typst')}
|
||||||
|
menuBarId="export-as-typst"
|
||||||
|
/>
|
||||||
|
<ExportProjectWithConversionButton
|
||||||
|
conversionType="latex"
|
||||||
|
label={t('export_as_latex')}
|
||||||
|
menuBarId="export-as-latex"
|
||||||
|
/>
|
||||||
<DropdownDivider />
|
<DropdownDivider />
|
||||||
<DuplicateProject />
|
<DuplicateProject />
|
||||||
<OLDropdownMenuItem
|
<OLDropdownMenuItem
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { postJSON } from '@/infrastructure/fetch-json'
|
||||||
|
import { useProjectContext } from '@/shared/context/project-context'
|
||||||
|
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import {
|
||||||
|
showExportDocumentError,
|
||||||
|
showExportDocumentSuccess,
|
||||||
|
hideExportDocumentError,
|
||||||
|
} from '../components/toolbar/export-document-toasts'
|
||||||
|
|
||||||
|
export default function useConvertDoc(
|
||||||
|
type: 'typst' | 'latex',
|
||||||
|
docId: string | null
|
||||||
|
) {
|
||||||
|
const { projectId } = useProjectContext()
|
||||||
|
const { dispatchCreateDoc } = useFileTreeData()
|
||||||
|
const [converting, setConverting] = useState(false)
|
||||||
|
|
||||||
|
const convert = useCallback(async () => {
|
||||||
|
if (!docId) return
|
||||||
|
setConverting(true)
|
||||||
|
hideExportDocumentError()
|
||||||
|
try {
|
||||||
|
const result = await postJSON(
|
||||||
|
`/project/${projectId}/doc/${docId}/convert/${type}`,
|
||||||
|
{ body: {} }
|
||||||
|
)
|
||||||
|
showExportDocumentSuccess(type)
|
||||||
|
if (result.isNew) {
|
||||||
|
dispatchCreateDoc(result.parentFolderId, {
|
||||||
|
_id: result.docId,
|
||||||
|
name: result.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage = err?.data?.error
|
||||||
|
showExportDocumentError(errorMessage)
|
||||||
|
} finally {
|
||||||
|
setConverting(false)
|
||||||
|
}
|
||||||
|
}, [projectId, docId, type, dispatchCreateDoc])
|
||||||
|
|
||||||
|
return { convert, converting }
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import { OpenDocuments } from '../editor/open-documents'
|
|||||||
const SLOW_CONVERSION_THRESHOLD = 2000
|
const SLOW_CONVERSION_THRESHOLD = 2000
|
||||||
|
|
||||||
export default function useConvertProject(
|
export default function useConvertProject(
|
||||||
type: 'docx' | 'markdown' | 'html',
|
type: 'docx' | 'markdown' | 'html' | 'typst' | 'latex',
|
||||||
openDocs: OpenDocuments,
|
openDocs: OpenDocuments,
|
||||||
getRootDocInfo: () => RootDocInfo
|
getRootDocInfo: () => RootDocInfo
|
||||||
) {
|
) {
|
||||||
@@ -45,7 +45,7 @@ export default function useConvertProject(
|
|||||||
if (downloadUrl) {
|
if (downloadUrl) {
|
||||||
const url = new URL(downloadUrl, window.location.origin)
|
const url = new URL(downloadUrl, window.location.origin)
|
||||||
location.assign(url.toString())
|
location.assign(url.toString())
|
||||||
showExportDocumentSuccess(type)
|
showExportDocumentSuccess(type as 'docx' | 'markdown' | 'html' | 'typst' | 'latex')
|
||||||
} else {
|
} else {
|
||||||
showExportDocumentError()
|
showExportDocumentError()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export const usePdfPane = () => {
|
|||||||
useLayoutContext()
|
useLayoutContext()
|
||||||
|
|
||||||
const pdfPanelRef = useRef<ImperativePanelHandle>(null)
|
const pdfPanelRef = useRef<ImperativePanelHandle>(null)
|
||||||
const pdfIsOpen = pdfLayout === 'sideBySide' || view === 'pdf'
|
const pdfIsOpen =
|
||||||
|
pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit' || view === 'pdf'
|
||||||
|
|
||||||
useCollapsiblePanel(pdfIsOpen, pdfPanelRef)
|
useCollapsiblePanel(pdfIsOpen, pdfPanelRef)
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ export const usePdfPane = () => {
|
|||||||
|
|
||||||
// triggered when the PDF pane becomes closed (either by dragging or toggling)
|
// triggered when the PDF pane becomes closed (either by dragging or toggling)
|
||||||
const handlePdfPaneCollapse = useCallback(() => {
|
const handlePdfPaneCollapse = useCallback(() => {
|
||||||
if (pdfLayout === 'sideBySide') {
|
if (pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit') {
|
||||||
changeLayout('flat', 'editor')
|
changeLayout('flat', 'editor')
|
||||||
}
|
}
|
||||||
}, [changeLayout, pdfLayout])
|
}, [changeLayout, pdfLayout])
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
import { postJSON } from '@/infrastructure/fetch-json'
|
||||||
|
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
|
||||||
|
import OLButton from '@/shared/components/ol/ol-button'
|
||||||
|
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||||
|
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||||
|
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||||
|
import { FormCheck } from 'react-bootstrap'
|
||||||
|
import MaterialIcon from '@/shared/components/material-icon'
|
||||||
|
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||||
|
|
||||||
|
type Action = 'save' | 'push' | 'pull'
|
||||||
|
type Status = 'idle' | 'busy' | 'success' | 'error' | 'confirm'
|
||||||
|
|
||||||
|
const projectId = getMeta('ol-project_id')
|
||||||
|
const AUTO_PUSH_KEY = `gitSync.autoPushOnCompile.${projectId}`
|
||||||
|
// Tracks the last subPath the user confirmed clearing on push
|
||||||
|
const CONFIRMED_SUBPATH_KEY = `gitSync.confirmedSubPath.${projectId}`
|
||||||
|
|
||||||
|
export default function GitSyncWidget() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { compiling, error: compileError, pdfFile, clsiServerId } =
|
||||||
|
useCompileContext()
|
||||||
|
|
||||||
|
const [remoteUrl, setRemoteUrl] = useState(getMeta('ol-gitRemote') ?? '')
|
||||||
|
const [subPath, setSubPath] = useState(getMeta('ol-gitSyncPath') ?? '')
|
||||||
|
const [pdfPath, setPdfPath] = useState(getMeta('ol-gitSyncPdfPath') ?? '')
|
||||||
|
const [branch, setBranch] = useState(getMeta('ol-gitSyncBranch') ?? 'main')
|
||||||
|
const [pushFiles, setPushFiles] = useState(getMeta('ol-gitSyncPushFiles') ?? true)
|
||||||
|
const [pushPdf, setPushPdf] = useState(getMeta('ol-gitSyncPushPdf') ?? true)
|
||||||
|
const [autoPush, setAutoPush] = useState(
|
||||||
|
() => localStorage.getItem(AUTO_PUSH_KEY) === 'true'
|
||||||
|
)
|
||||||
|
const [lastAction, setLastAction] = useState<Action>('save')
|
||||||
|
const [status, setStatus] = useState<Status>('idle')
|
||||||
|
const [errorMsg, setErrorMsg] = useState('')
|
||||||
|
|
||||||
|
// Pending push args while waiting for user to confirm subPath reset
|
||||||
|
const pendingPushRef = useRef<{ buildId?: string; serverId?: string } | null>(null)
|
||||||
|
|
||||||
|
const wasCompilingRef = useRef(false)
|
||||||
|
const isBusyRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isBusyRef.current = status === 'busy'
|
||||||
|
}, [status])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wasCompiling = wasCompilingRef.current
|
||||||
|
wasCompilingRef.current = compiling
|
||||||
|
|
||||||
|
if (
|
||||||
|
wasCompiling &&
|
||||||
|
!compiling &&
|
||||||
|
!compileError &&
|
||||||
|
autoPush &&
|
||||||
|
!isBusyRef.current
|
||||||
|
) {
|
||||||
|
doPush(pdfFile?.build, clsiServerId)
|
||||||
|
}
|
||||||
|
}, [compiling]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
await postJSON(`/project/${projectId}/git-sync`, {
|
||||||
|
body: { remoteUrl, subPath, pdfPath, branch, pushFiles, pushPdf },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractError(err: any): string {
|
||||||
|
return err?.data?.error ?? err?.message ?? String(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
function needsConfirmation(): boolean {
|
||||||
|
if (!pushFiles) return false
|
||||||
|
const confirmed = localStorage.getItem(CONFIRMED_SUBPATH_KEY)
|
||||||
|
return confirmed !== subPath
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmPush() {
|
||||||
|
localStorage.setItem(CONFIRMED_SUBPATH_KEY, subPath)
|
||||||
|
const args = pendingPushRef.current ?? {}
|
||||||
|
pendingPushRef.current = null
|
||||||
|
setStatus('idle')
|
||||||
|
executePush(args.buildId, args.serverId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelConfirm() {
|
||||||
|
pendingPushRef.current = null
|
||||||
|
setStatus('idle')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executePush(buildId?: string, serverId?: string) {
|
||||||
|
setLastAction('push')
|
||||||
|
setStatus('busy')
|
||||||
|
setErrorMsg('')
|
||||||
|
try {
|
||||||
|
await saveConfig()
|
||||||
|
await postJSON(`/project/${projectId}/git-sync/push`, {
|
||||||
|
body: { buildId, clsiServerId: serverId },
|
||||||
|
})
|
||||||
|
setStatus('success')
|
||||||
|
} catch (err: any) {
|
||||||
|
setStatus('error')
|
||||||
|
setErrorMsg(extractError(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doPush(buildId?: string, serverId?: string) {
|
||||||
|
if (needsConfirmation()) {
|
||||||
|
pendingPushRef.current = { buildId, serverId }
|
||||||
|
setStatus('confirm')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
executePush(buildId, serverId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setLastAction('save')
|
||||||
|
setStatus('busy')
|
||||||
|
setErrorMsg('')
|
||||||
|
try {
|
||||||
|
await saveConfig()
|
||||||
|
setStatus('success')
|
||||||
|
} catch (err: any) {
|
||||||
|
setStatus('error')
|
||||||
|
setErrorMsg(extractError(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePush() {
|
||||||
|
doPush(pdfFile?.build, clsiServerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePull() {
|
||||||
|
setLastAction('pull')
|
||||||
|
setStatus('busy')
|
||||||
|
setErrorMsg('')
|
||||||
|
try {
|
||||||
|
await saveConfig()
|
||||||
|
await postJSON(`/project/${projectId}/git-sync/pull`, { body: {} })
|
||||||
|
setStatus('success')
|
||||||
|
} catch (err: any) {
|
||||||
|
setStatus('error')
|
||||||
|
setErrorMsg(extractError(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAutoPushToggle(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const checked = e.target.checked
|
||||||
|
setAutoPush(checked)
|
||||||
|
localStorage.setItem(AUTO_PUSH_KEY, String(checked))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBusy = status === 'busy'
|
||||||
|
const isConfirm = status === 'confirm'
|
||||||
|
const nothingToPush = !pushFiles && (!pushPdf || !pdfPath.trim())
|
||||||
|
const managedArea = subPath.trim() || t('git_sync_repo_root')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="git-sync-widget">
|
||||||
|
<div className="git-sync-widget-header">
|
||||||
|
<MaterialIcon type="merge" />
|
||||||
|
<span className="git-sync-widget-title">{t('git_sync')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="git-sync-widget-description">{t('git_sync_description')}</p>
|
||||||
|
|
||||||
|
{isConfirm ? (
|
||||||
|
<div className="git-sync-confirm">
|
||||||
|
<OLNotification
|
||||||
|
type="warning"
|
||||||
|
content={t('git_sync_confirm_reset', { path: managedArea })}
|
||||||
|
/>
|
||||||
|
<div className="git-sync-widget-actions" style={{ marginTop: 'var(--spacing-04)' }}>
|
||||||
|
<OLButton
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={confirmPush}
|
||||||
|
>
|
||||||
|
{t('git_sync_confirm_push')}
|
||||||
|
</OLButton>
|
||||||
|
<OLButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={cancelConfirm}
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</OLButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSave}>
|
||||||
|
<OLFormGroup controlId="git-sync-remote-url">
|
||||||
|
<OLFormLabel>{t('git_sync_remote_url')}</OLFormLabel>
|
||||||
|
<OLFormControl
|
||||||
|
type="text"
|
||||||
|
value={remoteUrl}
|
||||||
|
onChange={e => setRemoteUrl(e.target.value)}
|
||||||
|
placeholder="https://user:token@github.com/org/repo.git"
|
||||||
|
disabled={isBusy}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
|
||||||
|
<OLFormGroup controlId="git-sync-branch">
|
||||||
|
<OLFormLabel>{t('git_sync_branch')}</OLFormLabel>
|
||||||
|
<OLFormControl
|
||||||
|
type="text"
|
||||||
|
value={branch}
|
||||||
|
onChange={e => setBranch(e.target.value)}
|
||||||
|
placeholder="main"
|
||||||
|
disabled={isBusy}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
|
||||||
|
<OLFormGroup controlId="git-sync-sub-path">
|
||||||
|
<OLFormLabel>{t('git_sync_sub_path')}</OLFormLabel>
|
||||||
|
<OLFormControl
|
||||||
|
type="text"
|
||||||
|
value={subPath}
|
||||||
|
onChange={e => setSubPath(e.target.value)}
|
||||||
|
placeholder={t('git_sync_sub_path_placeholder')}
|
||||||
|
disabled={isBusy}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
|
||||||
|
<OLFormGroup controlId="git-sync-push-files">
|
||||||
|
<FormCheck
|
||||||
|
type="switch"
|
||||||
|
id="git-sync-push-files"
|
||||||
|
label={t('git_sync_push_files')}
|
||||||
|
checked={pushFiles}
|
||||||
|
onChange={e => setPushFiles(e.target.checked)}
|
||||||
|
disabled={isBusy}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
|
||||||
|
<OLFormGroup controlId="git-sync-pdf-path">
|
||||||
|
<OLFormLabel>{t('git_sync_pdf_path')}</OLFormLabel>
|
||||||
|
<OLFormControl
|
||||||
|
type="text"
|
||||||
|
value={pdfPath}
|
||||||
|
onChange={e => setPdfPath(e.target.value)}
|
||||||
|
placeholder={t('git_sync_pdf_path_placeholder')}
|
||||||
|
disabled={isBusy}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
|
||||||
|
<OLFormGroup controlId="git-sync-push-pdf">
|
||||||
|
<FormCheck
|
||||||
|
type="switch"
|
||||||
|
id="git-sync-push-pdf"
|
||||||
|
label={t('git_sync_push_pdf')}
|
||||||
|
checked={pushPdf}
|
||||||
|
onChange={e => setPushPdf(e.target.checked)}
|
||||||
|
disabled={isBusy || !pdfPath.trim()}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
|
||||||
|
<OLFormGroup controlId="git-sync-auto-push">
|
||||||
|
<FormCheck
|
||||||
|
type="switch"
|
||||||
|
id="git-sync-auto-push"
|
||||||
|
label={t('git_sync_auto_push_on_compile')}
|
||||||
|
checked={autoPush}
|
||||||
|
onChange={handleAutoPushToggle}
|
||||||
|
disabled={isBusy || !remoteUrl.trim() || nothingToPush}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
|
||||||
|
<div className="git-sync-widget-actions">
|
||||||
|
<OLButton
|
||||||
|
type="submit"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={isBusy || !remoteUrl.trim()}
|
||||||
|
isLoading={isBusy && lastAction === 'save'}
|
||||||
|
loadingLabel={t('saving')}
|
||||||
|
>
|
||||||
|
{t('save')}
|
||||||
|
</OLButton>
|
||||||
|
<OLButton
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePush}
|
||||||
|
disabled={isBusy || !remoteUrl.trim() || nothingToPush}
|
||||||
|
isLoading={isBusy && lastAction === 'push'}
|
||||||
|
loadingLabel={t('git_sync_pushing')}
|
||||||
|
>
|
||||||
|
{t('git_sync_push_now')}
|
||||||
|
</OLButton>
|
||||||
|
<OLButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePull}
|
||||||
|
disabled={isBusy || !remoteUrl.trim()}
|
||||||
|
isLoading={isBusy && lastAction === 'pull'}
|
||||||
|
loadingLabel={t('git_sync_pulling')}
|
||||||
|
>
|
||||||
|
{t('git_sync_pull_now')}
|
||||||
|
</OLButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<OLNotification
|
||||||
|
type="success"
|
||||||
|
content={
|
||||||
|
lastAction === 'push'
|
||||||
|
? t('git_sync_push_success')
|
||||||
|
: lastAction === 'pull'
|
||||||
|
? t('git_sync_pull_success')
|
||||||
|
: t('git_sync_saved')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<OLNotification type="error" content={errorMsg} />
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { ElementType } from 'react'
|
|||||||
import importOverleafModules from '../../../macros/import-overleaf-module.macro'
|
import importOverleafModules from '../../../macros/import-overleaf-module.macro'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import RailPanelHeader from '@/features/ide-react/components/rail/rail-panel-header'
|
import RailPanelHeader from '@/features/ide-react/components/rail/rail-panel-header'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
import GitSyncWidget from './git-sync-widget'
|
||||||
|
|
||||||
const integrationPanelComponents = importOverleafModules(
|
const integrationPanelComponents = importOverleafModules(
|
||||||
'integrationPanelComponents'
|
'integrationPanelComponents'
|
||||||
@@ -9,10 +11,12 @@ const integrationPanelComponents = importOverleafModules(
|
|||||||
|
|
||||||
export default function IntegrationsPanel() {
|
export default function IntegrationsPanel() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const gitSyncEnabled = getMeta('ol-gitSyncEnabled')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="integrations-panel">
|
<div className="integrations-panel">
|
||||||
<RailPanelHeader title={t('integrations')} />
|
<RailPanelHeader title={t('integrations')} />
|
||||||
|
{gitSyncEnabled && <GitSyncWidget />}
|
||||||
{integrationPanelComponents.map(
|
{integrationPanelComponents.map(
|
||||||
({ import: { default: Component }, path }) => (
|
({ import: { default: Component }, path }) => (
|
||||||
<Component key={path} />
|
<Component key={path} />
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ function PdfCompileButton() {
|
|||||||
smoothPdfTransition,
|
smoothPdfTransition,
|
||||||
setSmoothPdfTransition,
|
setSmoothPdfTransition,
|
||||||
isLatexProject,
|
isLatexProject,
|
||||||
|
isTypstProject,
|
||||||
|
typstPreviewMode,
|
||||||
|
setTypstPreviewMode,
|
||||||
} = useCompileContext()
|
} = useCompileContext()
|
||||||
const { enableStopOnFirstError, disableStopOnFirstError } =
|
const { enableStopOnFirstError, disableStopOnFirstError } =
|
||||||
useStopOnFirstError({ eventSource: 'dropdown' })
|
useStopOnFirstError({ eventSource: 'dropdown' })
|
||||||
@@ -172,6 +175,33 @@ function PdfCompileButton() {
|
|||||||
{t('off')}
|
{t('off')}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</li>
|
</li>
|
||||||
|
{isTypstProject && (
|
||||||
|
<>
|
||||||
|
<DropdownDivider />
|
||||||
|
<DropdownHeader>{t('typst_preview_mode')}</DropdownHeader>
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
onClick={() => setTypstPreviewMode('wasm')}
|
||||||
|
trailingIcon={typstPreviewMode === 'wasm' ? 'check' : null}
|
||||||
|
>
|
||||||
|
{t('typst_preview_wasm')}{' '}
|
||||||
|
<span className="typst-preview-experimental-badge">
|
||||||
|
{t('experimental')}
|
||||||
|
</span>
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
onClick={() => setTypstPreviewMode('pdf')}
|
||||||
|
trailingIcon={typstPreviewMode === 'pdf' ? 'check' : null}
|
||||||
|
>
|
||||||
|
{t('typst_preview_pdf')}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{isLatexProject && (
|
{isLatexProject && (
|
||||||
<>
|
<>
|
||||||
<DropdownDivider />
|
<DropdownDivider />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ElementType, memo, Suspense } from 'react'
|
import { ElementType, lazy, memo, Suspense } from 'react'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import PdfViewer from './pdf-viewer'
|
import PdfViewer from './pdf-viewer'
|
||||||
import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner'
|
import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner'
|
||||||
@@ -12,12 +12,22 @@ import PdfCodeCheckFailedBanner from '@/features/pdf-preview/components/pdf-code
|
|||||||
import getMeta from '@/utils/meta'
|
import getMeta from '@/utils/meta'
|
||||||
import PdfLogsViewer from '@/features/pdf-preview/components/pdf-logs-viewer'
|
import PdfLogsViewer from '@/features/pdf-preview/components/pdf-logs-viewer'
|
||||||
|
|
||||||
|
const TypstWasmPreview = lazy(
|
||||||
|
() =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "typst-wasm-preview" */
|
||||||
|
'@/features/typst-preview/components/typst-wasm-preview'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
function PdfPreviewPane() {
|
function PdfPreviewPane() {
|
||||||
const {
|
const {
|
||||||
pdfUrl,
|
pdfUrl,
|
||||||
pdfViewer,
|
pdfViewer,
|
||||||
darkModePdf: darkModeSetting,
|
darkModePdf: darkModeSetting,
|
||||||
activeOverallTheme,
|
activeOverallTheme,
|
||||||
|
isTypstProject,
|
||||||
|
typstPreviewMode,
|
||||||
} = useCompileContext()
|
} = useCompileContext()
|
||||||
const { compileTimeout } = getMeta('ol-compileSettings')
|
const { compileTimeout } = getMeta('ol-compileSettings')
|
||||||
const isHtmlOutput = pdfUrl?.includes('output.html')
|
const isHtmlOutput = pdfUrl?.includes('output.html')
|
||||||
@@ -26,8 +36,9 @@ function PdfPreviewPane() {
|
|||||||
activeOverallTheme === 'dark' &&
|
activeOverallTheme === 'dark' &&
|
||||||
darkModeSetting
|
darkModeSetting
|
||||||
|
|
||||||
|
const isWasmMode = isTypstProject && typstPreviewMode === 'wasm'
|
||||||
const classes = classNames('pdf', 'full-size', {
|
const classes = classNames('pdf', 'full-size', {
|
||||||
'pdf-empty': !pdfUrl,
|
'pdf-empty': !pdfUrl && !isWasmMode,
|
||||||
'pdf-dark-mode': darkModePdf,
|
'pdf-dark-mode': darkModePdf,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -46,7 +57,11 @@ function PdfPreviewPane() {
|
|||||||
</PdfPreviewMessages>
|
</PdfPreviewMessages>
|
||||||
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
|
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
|
||||||
<div className="pdf-viewer" data-testid="pdf-viewer">
|
<div className="pdf-viewer" data-testid="pdf-viewer">
|
||||||
<PdfViewer />
|
{isWasmMode ? (
|
||||||
|
<TypstWasmPreview />
|
||||||
|
) : (
|
||||||
|
<PdfViewer />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<PdfLogsViewer />
|
<PdfLogsViewer />
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ function SwitchToEditorButton() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pdfLayout === 'sideBySide') {
|
if (pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+76
-26
@@ -15,6 +15,7 @@ import LeaveProjectButton from '../table/cells/action-buttons/leave-project-butt
|
|||||||
import DeleteProjectButton from '../table/cells/action-buttons/delete-project-button'
|
import DeleteProjectButton from '../table/cells/action-buttons/delete-project-button'
|
||||||
import { Project } from '../../../../../../types/project/dashboard/api'
|
import { Project } from '../../../../../../types/project/dashboard/api'
|
||||||
import CompileAndDownloadProjectPDFButton from '../table/cells/action-buttons/compile-and-download-project-pdf-button'
|
import CompileAndDownloadProjectPDFButton from '../table/cells/action-buttons/compile-and-download-project-pdf-button'
|
||||||
|
import DownloadPresentationButton from '../table/cells/action-buttons/download-presentation-button'
|
||||||
import RenameProjectButton from '../table/cells/action-buttons/rename-project-button'
|
import RenameProjectButton from '../table/cells/action-buttons/rename-project-button'
|
||||||
import MaterialIcon from '@/shared/components/material-icon'
|
import MaterialIcon from '@/shared/components/material-icon'
|
||||||
import OLSpinner from '@/shared/components/ol/ol-spinner'
|
import OLSpinner from '@/shared/components/ol/ol-spinner'
|
||||||
@@ -77,32 +78,81 @@ function ActionsDropdown({ project }: ActionDropdownProps) {
|
|||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</DownloadProjectButton>
|
</DownloadProjectButton>
|
||||||
<CompileAndDownloadProjectPDFButton project={project}>
|
{project.compiler === 'quarto' ? (
|
||||||
{(text, pendingCompile, downloadProject) => (
|
<DownloadPresentationButton project={project}>
|
||||||
<li role="none">
|
{(startExport, exporting) => (
|
||||||
<DropdownItem
|
<>
|
||||||
as="button"
|
<li role="none">
|
||||||
tabIndex={-1}
|
<DropdownItem
|
||||||
onClick={e => {
|
as="button"
|
||||||
e.stopPropagation()
|
tabIndex={-1}
|
||||||
downloadProject()
|
onClick={() => startExport('html')}
|
||||||
}}
|
disabled={exporting !== null}
|
||||||
leadingIcon={
|
leadingIcon={
|
||||||
pendingCompile ? (
|
exporting === 'html' ? (
|
||||||
<OLSpinner
|
<OLSpinner
|
||||||
size="sm"
|
size="sm"
|
||||||
className="dropdown-item-leading-icon spinner"
|
className="dropdown-item-leading-icon spinner"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
'picture_as_pdf'
|
'download'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{text}
|
{t('download_as_standalone_html')}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</li>
|
</li>
|
||||||
)}
|
<li role="none">
|
||||||
</CompileAndDownloadProjectPDFButton>
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={() => startExport('pdf')}
|
||||||
|
disabled={exporting !== null}
|
||||||
|
leadingIcon={
|
||||||
|
exporting === 'pdf' ? (
|
||||||
|
<OLSpinner
|
||||||
|
size="sm"
|
||||||
|
className="dropdown-item-leading-icon spinner"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'picture_as_pdf'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('download_as_pdf_slides')}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DownloadPresentationButton>
|
||||||
|
) : (
|
||||||
|
<CompileAndDownloadProjectPDFButton project={project}>
|
||||||
|
{(text, pendingCompile, downloadProject) => (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
downloadProject()
|
||||||
|
}}
|
||||||
|
leadingIcon={
|
||||||
|
pendingCompile ? (
|
||||||
|
<OLSpinner
|
||||||
|
size="sm"
|
||||||
|
className="dropdown-item-leading-icon spinner"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'picture_as_pdf'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</CompileAndDownloadProjectPDFButton>
|
||||||
|
)}
|
||||||
<ArchiveProjectButton project={project}>
|
<ArchiveProjectButton project={project}>
|
||||||
{(text, handleOpenModal) => (
|
{(text, handleOpenModal) => (
|
||||||
<li role="none">
|
<li role="none">
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function NewProjectButton({
|
|||||||
const markdownImportEnabled =
|
const markdownImportEnabled =
|
||||||
useFeatureFlag('import-markdown') &&
|
useFeatureFlag('import-markdown') &&
|
||||||
getMeta('ol-ExposedSettings').enablePandocConversions
|
getMeta('ol-ExposedSettings').enablePandocConversions
|
||||||
const { selectedTagId, tags } = useProjectListContext()
|
const { selectedTagId, tags } = useProjectListContext()
|
||||||
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
|
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
|
||||||
const initialTags =
|
const initialTags =
|
||||||
isLibraryEnabled && selectedTagId
|
isLibraryEnabled && selectedTagId
|
||||||
|
|||||||
+7
-1
@@ -19,7 +19,7 @@ function ImportDocumentModal({
|
|||||||
onHide,
|
onHide,
|
||||||
openProject,
|
openProject,
|
||||||
}: {
|
}: {
|
||||||
type: 'docx' | 'markdown'
|
type: 'docx' | 'markdown' | 'typst'
|
||||||
onHide: () => void
|
onHide: () => void
|
||||||
openProject: (id: string, convertedFrom?: string) => void
|
openProject: (id: string, convertedFrom?: string) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -39,6 +39,12 @@ function ImportDocumentModal({
|
|||||||
browseLabel: 'Select .md file',
|
browseLabel: 'Select .md file',
|
||||||
dragLabel: '%{browseFiles} or \n\n Drag .md file',
|
dragLabel: '%{browseFiles} or \n\n Drag .md file',
|
||||||
},
|
},
|
||||||
|
typst: {
|
||||||
|
allowedFileTypes: ['.typ'],
|
||||||
|
title: t('choose_typst_file'),
|
||||||
|
browseLabel: 'Select .typ file',
|
||||||
|
dragLabel: '%{browseFiles} or \n\n Drag .typ file',
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[t]
|
[t]
|
||||||
)
|
)
|
||||||
|
|||||||
+11
@@ -21,6 +21,7 @@ export type NewProjectButtonModalVariant =
|
|||||||
| 'import_from_github'
|
| 'import_from_github'
|
||||||
| 'import_docx'
|
| 'import_docx'
|
||||||
| 'import_markdown'
|
| 'import_markdown'
|
||||||
|
| 'import_typst'
|
||||||
|
|
||||||
type NewProjectButtonModalProps = {
|
type NewProjectButtonModalProps = {
|
||||||
modal: Nullable<NewProjectButtonModalVariant>
|
modal: Nullable<NewProjectButtonModalVariant>
|
||||||
@@ -128,6 +129,16 @@ function NewProjectButtonModal({
|
|||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
|
case 'import_typst':
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
|
||||||
|
<ImportDocumentModal
|
||||||
|
type="typst"
|
||||||
|
onHide={onHide}
|
||||||
|
openProject={openProject}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
case 'import_from_github':
|
case 'import_from_github':
|
||||||
return <ImportProjectFromGithubModalWrapper onHide={onHide} />
|
return <ImportProjectFromGithubModalWrapper onHide={onHide} />
|
||||||
default:
|
default:
|
||||||
|
|||||||
+5
-4
@@ -128,20 +128,21 @@ function BannerContent({
|
|||||||
}: {
|
}: {
|
||||||
variant: GroupsAndEnterpriseBannerVariant
|
variant: GroupsAndEnterpriseBannerVariant
|
||||||
}) {
|
}) {
|
||||||
|
const { appName } = getMeta('ol-ExposedSettings')
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case 'on-premise':
|
case 'on-premise':
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
Overleaf On-Premises: Does your company want to keep its data within
|
{appName} On-Premises: Does your company want to keep its data within
|
||||||
its firewall? Overleaf offers Server Pro, an on-premises solution for
|
its firewall? {appName} offers Server Pro, an on-premises solution for
|
||||||
companies. Get in touch to learn more.
|
companies. Get in touch to learn more.
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
case 'FOMO':
|
case 'FOMO':
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
Why do Fortune 500 companies and top research institutions trust
|
Why do Fortune 500 companies and top research institutions trust{' '}
|
||||||
Overleaf to streamline their collaboration? Get in touch to learn
|
{appName} to streamline their collaboration? Get in touch to learn
|
||||||
more.
|
more.
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,37 +44,19 @@ export function ProjectListDsNav() {
|
|||||||
|
|
||||||
const tableTopArea = (
|
const tableTopArea = (
|
||||||
<div className="pt-2 pb-3 d-md-none d-flex gap-2">
|
<div className="pt-2 pb-3 d-md-none d-flex gap-2">
|
||||||
{isLibraryEnabled ? (
|
{showNewProjectButton && (
|
||||||
<>
|
<NewProjectButton
|
||||||
<SearchForm
|
id="new-project-button-projects-table"
|
||||||
inputValue={searchText}
|
showAddAffiliationWidget
|
||||||
setInputValue={setSearchText}
|
/>
|
||||||
filter={filter}
|
|
||||||
selectedTag={selectedTag}
|
|
||||||
className="overflow-hidden flex-grow-1"
|
|
||||||
/>
|
|
||||||
{showNewProjectButton && (
|
|
||||||
<NewProjectButton
|
|
||||||
id="new-project-button-projects-table"
|
|
||||||
showAddAffiliationWidget
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<NewProjectButton
|
|
||||||
id="new-project-button-projects-table"
|
|
||||||
showAddAffiliationWidget
|
|
||||||
/>
|
|
||||||
<SearchForm
|
|
||||||
inputValue={searchText}
|
|
||||||
setInputValue={setSearchText}
|
|
||||||
filter={filter}
|
|
||||||
selectedTag={selectedTag}
|
|
||||||
className="overflow-hidden flex-grow-1"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<SearchForm
|
||||||
|
inputValue={searchText}
|
||||||
|
setInputValue={setSearchText}
|
||||||
|
filter={filter}
|
||||||
|
selectedTag={selectedTag}
|
||||||
|
className="overflow-hidden flex-grow-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,9 +89,6 @@ export function ProjectListDsNav() {
|
|||||||
<ProjectTools />
|
<ProjectTools />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="d-md-none">
|
|
||||||
<CurrentPlanWidget />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="project-ds-nav-project-list">
|
<div className="project-ds-nav-project-list">
|
||||||
@@ -145,8 +124,8 @@ export function ProjectListDsNav() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
|
{tableTopArea}
|
||||||
<TableContainer bordered>
|
<TableContainer bordered>
|
||||||
{tableTopArea}
|
|
||||||
<ProjectListTable />
|
<ProjectListTable />
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,485 @@
|
|||||||
|
import React, {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
Filter,
|
||||||
|
useProjectListContext,
|
||||||
|
} from '../context/project-list-context'
|
||||||
|
import { Project } from '../../../../../types/project/dashboard/api'
|
||||||
|
import { getOwnerName } from '../util/project'
|
||||||
|
import { fromNowDate } from '../../../utils/dates'
|
||||||
|
import { ProjectCompiler } from '../../../../../types/project-settings'
|
||||||
|
import { getTagColor } from '../util/tag'
|
||||||
|
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
import DefaultNavbar from '@/shared/components/navbar/default-navbar'
|
||||||
|
import Footer from '@/shared/components/footer/footer'
|
||||||
|
import SidebarDsNav from '@/features/project-list/components/sidebar/sidebar-ds-nav'
|
||||||
|
import SystemMessages from '@/shared/components/system-messages'
|
||||||
|
import CookieBanner from '@/shared/components/cookie-banner'
|
||||||
|
import UserNotifications from './notifications/user-notifications'
|
||||||
|
import SearchForm from './search-form'
|
||||||
|
import NewProjectButton from './new-project-button'
|
||||||
|
import ProjectListTitle from './title/project-list-title'
|
||||||
|
import LoadMore from './load-more'
|
||||||
|
import DashApiError from './dash-api-error'
|
||||||
|
import { ProjectCheckbox } from './table/project-checkbox'
|
||||||
|
import ProjectTools from './table/project-tools/project-tools'
|
||||||
|
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
|
||||||
|
import { CopyProjectButtonTooltip } from './table/cells/action-buttons/copy-project-button'
|
||||||
|
import { DownloadProjectButtonTooltip } from './table/cells/action-buttons/download-project-button'
|
||||||
|
import { CompileAndDownloadProjectPDFButtonTooltip } from './table/cells/action-buttons/compile-and-download-project-pdf-button'
|
||||||
|
import { DownloadPresentationButtonTooltip } from './table/cells/action-buttons/download-presentation-button'
|
||||||
|
import { ArchiveProjectButtonTooltip } from './table/cells/action-buttons/archive-project-button'
|
||||||
|
import { TrashProjectButtonTooltip } from './table/cells/action-buttons/trash-project-button'
|
||||||
|
import FormatCell from './table/cells/format-cell'
|
||||||
|
import OwnerCell from './table/cells/owner-cell'
|
||||||
|
import LastUpdatedCell from './table/cells/last-updated-cell'
|
||||||
|
import ActionsCell from './table/cells/actions-cell'
|
||||||
|
import ActionsDropdown from './dropdown/actions-dropdown'
|
||||||
|
import InlineTags from './table/cells/inline-tags'
|
||||||
|
import WelcomePageContent from './welcome-page-content'
|
||||||
|
|
||||||
|
// ── Mobile breakpoint ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function useIsMobile() {
|
||||||
|
const [mobile, setMobile] = useState(
|
||||||
|
() => window.matchMedia('(max-width: 767px)').matches
|
||||||
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(max-width: 767px)')
|
||||||
|
const handler = (e: MediaQueryListEvent) => setMobile(e.matches)
|
||||||
|
mq.addEventListener('change', handler)
|
||||||
|
return () => mq.removeEventListener('change', handler)
|
||||||
|
}, [])
|
||||||
|
return mobile
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tile zoom ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ZoomLevel = 0 | 1 | 1.35 | 1.75
|
||||||
|
const ZOOM_OPTIONS: { value: ZoomLevel; label: string }[] = [
|
||||||
|
{ value: 0, label: 'XS' },
|
||||||
|
{ value: 1, label: 'S' },
|
||||||
|
{ value: 1.35, label: 'M' },
|
||||||
|
{ value: 1.75, label: 'L' },
|
||||||
|
]
|
||||||
|
// Mobile offers 3 sizes: XS (rows), M (2 tiles), L (1 tile)
|
||||||
|
const MOBILE_ZOOM_OPTIONS: { value: ZoomLevel; label: string }[] = [
|
||||||
|
{ value: 0, label: 'XS' },
|
||||||
|
{ value: 1, label: 'M' },
|
||||||
|
{ value: 1.35, label: 'L' },
|
||||||
|
]
|
||||||
|
const ZOOM_STORAGE_KEY = 'lumiere-card-scale'
|
||||||
|
|
||||||
|
function useLumiereCardScale(): [ZoomLevel, (z: ZoomLevel) => void] {
|
||||||
|
const [scale, setScale] = useState<ZoomLevel>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(ZOOM_STORAGE_KEY)
|
||||||
|
if (stored) {
|
||||||
|
const val = parseFloat(stored)
|
||||||
|
if ((ZOOM_OPTIONS.map(o => o.value) as number[]).includes(val)) {
|
||||||
|
return val as ZoomLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// storage unavailable
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateScale = useCallback((z: ZoomLevel) => {
|
||||||
|
setScale(z)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ZOOM_STORAGE_KEY, String(z))
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return [scale, updateScale]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Format helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type FormatVariant = 'latex' | 'typst' | 'quarto' | 'quarto-slides'
|
||||||
|
|
||||||
|
function getFormatVariant(
|
||||||
|
compiler: ProjectCompiler | undefined,
|
||||||
|
quartoFlavor: 'revealjs' | 'pdf' | undefined
|
||||||
|
): FormatVariant {
|
||||||
|
if (compiler === 'quarto') {
|
||||||
|
return quartoFlavor === 'revealjs' ? 'quarto-slides' : 'quarto'
|
||||||
|
}
|
||||||
|
if (compiler === 'typst') return 'typst'
|
||||||
|
return 'latex'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormatLabel(variant: FormatVariant): string {
|
||||||
|
switch (variant) {
|
||||||
|
case 'typst':
|
||||||
|
return 'Typst'
|
||||||
|
case 'quarto':
|
||||||
|
return 'Quarto'
|
||||||
|
case 'quarto-slides':
|
||||||
|
return 'Quarto Slides'
|
||||||
|
default:
|
||||||
|
return 'LaTeX'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProjectCard = memo(function ProjectCard({
|
||||||
|
project,
|
||||||
|
}: {
|
||||||
|
project: Project
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { tags } = useProjectListContext()
|
||||||
|
const variant = getFormatVariant(project.compiler, project.quartoFlavor)
|
||||||
|
const rawOwner = getOwnerName(project)
|
||||||
|
const ownerName = rawOwner === 'You' ? t('you') : rawOwner
|
||||||
|
const date = fromNowDate(project.lastUpdated)
|
||||||
|
const initial = project.name.charAt(0).toUpperCase() || '?'
|
||||||
|
const projectTags = tags
|
||||||
|
.filter(tag => tag.project_ids?.includes(project.id))
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lumiere-card-wrapper">
|
||||||
|
<div className="lumiere-card-checkbox">
|
||||||
|
<ProjectCheckbox projectId={project.id} projectName={project.name} />
|
||||||
|
</div>
|
||||||
|
<div className={`lumiere-card lumiere-card--${variant}`} translate="no">
|
||||||
|
<a
|
||||||
|
href={`/project/${project.id}`}
|
||||||
|
className="lumiere-card-link"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
<div className="lumiere-card-thumb">
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||||
|
<img
|
||||||
|
className="lumiere-card-thumb-img"
|
||||||
|
src={`/project/${project.id}/thumbnail`}
|
||||||
|
aria-hidden="true"
|
||||||
|
onError={e => {
|
||||||
|
e.currentTarget.style.display = 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="lumiere-card-initial">{initial}</span>
|
||||||
|
</div>
|
||||||
|
<div className="lumiere-card-body">
|
||||||
|
<span className="lumiere-card-name">{project.name}</span>
|
||||||
|
<div className="lumiere-card-meta">
|
||||||
|
<span
|
||||||
|
className={`lumiere-format-badge lumiere-format-badge--${variant}`}
|
||||||
|
>
|
||||||
|
{getFormatLabel(variant)}
|
||||||
|
</span>
|
||||||
|
{ownerName && (
|
||||||
|
<span className="lumiere-card-owner" translate="yes">
|
||||||
|
{ownerName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{projectTags.map(tag => (
|
||||||
|
<OLTooltip
|
||||||
|
key={tag._id}
|
||||||
|
id={`tag-${tag._id}-${project.id}`}
|
||||||
|
description={tag.name}
|
||||||
|
overlayProps={{ placement: 'top' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="lumiere-card-tag-dot"
|
||||||
|
style={{ backgroundColor: getTagColor(tag) }}
|
||||||
|
translate="no"
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
))}</div>
|
||||||
|
<span className="lumiere-card-date">{date}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div className="lumiere-card-actions">
|
||||||
|
<CopyProjectButtonTooltip project={project} />
|
||||||
|
<DownloadProjectButtonTooltip project={project} />
|
||||||
|
{project.compiler === 'quarto' ? (
|
||||||
|
<DownloadPresentationButtonTooltip project={project} />
|
||||||
|
) : (
|
||||||
|
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
|
||||||
|
)}
|
||||||
|
<ArchiveProjectButtonTooltip project={project} />
|
||||||
|
<TrashProjectButtonTooltip project={project} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── XS compact row (reuses classic table cell components for full detail) ─────
|
||||||
|
|
||||||
|
const ProjectCardCompact = memo(function ProjectCardCompact({
|
||||||
|
project,
|
||||||
|
}: {
|
||||||
|
project: Project
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="lumiere-compact-row">
|
||||||
|
<div className="lumiere-compact-checkbox">
|
||||||
|
<ProjectCheckbox projectId={project.id} projectName={project.name} />
|
||||||
|
</div>
|
||||||
|
<div className="lumiere-compact-name-cell">
|
||||||
|
<a
|
||||||
|
href={`/project/${project.id}`}
|
||||||
|
className="lumiere-compact-name"
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</a>
|
||||||
|
<InlineTags projectId={project.id} />
|
||||||
|
</div>
|
||||||
|
<div className="lumiere-compact-meta">
|
||||||
|
<div className="lumiere-compact-format">
|
||||||
|
<FormatCell project={project} />
|
||||||
|
</div>
|
||||||
|
<div className="lumiere-compact-owner" translate="no">
|
||||||
|
<OwnerCell project={project} />
|
||||||
|
</div>
|
||||||
|
<div className="lumiere-compact-date">
|
||||||
|
<LastUpdatedCell project={project} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lumiere-compact-actions">
|
||||||
|
{/* ⋮ dropdown on mobile (single button), icon strip on desktop */}
|
||||||
|
<div className="d-md-none">
|
||||||
|
<ActionsDropdown project={project} />
|
||||||
|
</div>
|
||||||
|
<div className="d-none d-md-flex">
|
||||||
|
<ActionsCell project={project} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function ProjectListLumiere() {
|
||||||
|
const navbarProps = getMeta('ol-navbar')
|
||||||
|
const footerProps = getMeta('ol-footer')
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [cardScale, setCardScale] = useLumiereCardScale()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const {
|
||||||
|
error,
|
||||||
|
visibleProjects,
|
||||||
|
totalProjectsCount,
|
||||||
|
searchText,
|
||||||
|
setSearchText,
|
||||||
|
filter,
|
||||||
|
tags,
|
||||||
|
selectedTagId,
|
||||||
|
selectedProjects,
|
||||||
|
selectOrUnselectAllProjects,
|
||||||
|
selectFilter,
|
||||||
|
} = useProjectListContext()
|
||||||
|
|
||||||
|
// On mobile: M option (scale=1) → 2 tiles/row; L (scale≥1.35) → 1 tile/row.
|
||||||
|
// CSS overrides the card grid columns on mobile; we only add a class for L.
|
||||||
|
const mobileIsL = isMobile && cardScale !== 0 && cardScale >= 1.35
|
||||||
|
const activeMobileZoom = cardScale === 0
|
||||||
|
? 0
|
||||||
|
: cardScale >= 1.35
|
||||||
|
? 1.35
|
||||||
|
: 1
|
||||||
|
|
||||||
|
const selectedTag = tags.find(tag => tag._id === selectedTagId)
|
||||||
|
const allSelected =
|
||||||
|
visibleProjects.length > 0 &&
|
||||||
|
selectedProjects.length === visibleProjects.length
|
||||||
|
|
||||||
|
const MOBILE_FILTERS: { f: Filter; label: string }[] = [
|
||||||
|
{ f: 'all', label: t('all_projects') },
|
||||||
|
{ f: 'owned', label: t('your_projects') },
|
||||||
|
{ f: 'shared', label: t('shared_with_you') },
|
||||||
|
{ f: 'archived', label: t('archived_projects') },
|
||||||
|
{ f: 'trashed', label: t('trashed_projects') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const checkAllRef = useRef<HTMLInputElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (checkAllRef.current) {
|
||||||
|
checkAllRef.current.indeterminate =
|
||||||
|
selectedProjects.length > 0 && !allSelected
|
||||||
|
}
|
||||||
|
}, [selectedProjects, allSelected])
|
||||||
|
|
||||||
|
const handleSelectAll = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
selectOrUnselectAllProjects(e.target.checked)
|
||||||
|
},
|
||||||
|
[selectOrUnselectAllProjects]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDeselectAll = useCallback(() => {
|
||||||
|
selectOrUnselectAllProjects(false)
|
||||||
|
}, [selectOrUnselectAllProjects])
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Keep project-ds-nav-page + website-redesign so the sidebar and navbar
|
||||||
|
// pick up all their existing CSS (scoped under those classes). The
|
||||||
|
// project-list-lumiere class then adds/overrides the card-grid styles.
|
||||||
|
<div className="project-ds-nav-page website-redesign project-list-lumiere">
|
||||||
|
<SystemMessages />
|
||||||
|
<DefaultNavbar {...navbarProps} showCloseIcon />
|
||||||
|
{/* project-list-wrapper is required by sidebar CSS */}
|
||||||
|
<div className="project-list-wrapper">
|
||||||
|
<SidebarDsNav />
|
||||||
|
<div className="project-ds-nav-content-and-messages lumiere-content-area">
|
||||||
|
<div className="project-ds-nav-content lumiere-scroll-area">
|
||||||
|
<main
|
||||||
|
className="project-ds-nav-main lumiere-main"
|
||||||
|
aria-labelledby="lumiere-title"
|
||||||
|
>
|
||||||
|
<UserNotifications />
|
||||||
|
{error && <DashApiError />}
|
||||||
|
<div className="lumiere-header">
|
||||||
|
<div className="lumiere-title-row">
|
||||||
|
<ProjectListTitle
|
||||||
|
filter={filter}
|
||||||
|
selectedTag={selectedTag}
|
||||||
|
selectedTagId={selectedTagId}
|
||||||
|
className="lumiere-title"
|
||||||
|
id="lumiere-title"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="lumiere-zoom-control d-none d-md-flex"
|
||||||
|
role="group"
|
||||||
|
aria-label={t('card_size')}
|
||||||
|
>
|
||||||
|
{ZOOM_OPTIONS.map(({ value, label }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={`lumiere-zoom-btn${cardScale === value ? ' active' : ''}`}
|
||||||
|
onClick={() => setCardScale(value)}
|
||||||
|
aria-pressed={cardScale === value}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Mobile-only: filter pills */}
|
||||||
|
<div className="d-flex d-md-none lumiere-mobile-toolbar">
|
||||||
|
<div
|
||||||
|
className="lumiere-mobile-filters"
|
||||||
|
role="group"
|
||||||
|
aria-label={t('filter_projects')}
|
||||||
|
>
|
||||||
|
{MOBILE_FILTERS.map(({ f, label }) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
type="button"
|
||||||
|
className={`lumiere-mobile-filter-pill${filter === f ? ' active' : ''}`}
|
||||||
|
onClick={() => selectFilter(f)}
|
||||||
|
aria-pressed={filter === f}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lumiere-header-actions">
|
||||||
|
<SearchForm
|
||||||
|
inputValue={searchText}
|
||||||
|
setInputValue={setSearchText}
|
||||||
|
filter={filter}
|
||||||
|
selectedTag={selectedTag}
|
||||||
|
/>
|
||||||
|
{/* On mobile: new project + zoom share a row below the search bar */}
|
||||||
|
<div className="lumiere-header-actions-row">
|
||||||
|
<NewProjectButton
|
||||||
|
id="lumiere-new-project-button"
|
||||||
|
showAddAffiliationWidget
|
||||||
|
/>
|
||||||
|
{/* Zoom control: on desktop it sits in the title row; on mobile it moves here */}
|
||||||
|
<div
|
||||||
|
className="lumiere-zoom-control lumiere-mobile-zoom d-md-none"
|
||||||
|
role="group"
|
||||||
|
aria-label={t('card_size')}
|
||||||
|
>
|
||||||
|
{MOBILE_ZOOM_OPTIONS.map(({ value, label }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={`lumiere-zoom-btn${activeMobileZoom === value ? ' active' : ''}`}
|
||||||
|
onClick={() => setCardScale(value)}
|
||||||
|
aria-pressed={activeMobileZoom === value}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedProjects.length > 0 && (
|
||||||
|
<div className="lumiere-selection-bar">
|
||||||
|
<div className="lumiere-selection-bar-left">
|
||||||
|
<OLFormCheckbox
|
||||||
|
autoComplete="off"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
inputRef={checkAllRef}
|
||||||
|
aria-label={t('select_all_projects')}
|
||||||
|
/>
|
||||||
|
<span className="lumiere-selection-count">
|
||||||
|
{t('n_projects_selected', {
|
||||||
|
count: selectedProjects.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProjectTools />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lumiere-selection-deselect"
|
||||||
|
onClick={handleDeselectAll}
|
||||||
|
>
|
||||||
|
{t('deselect_all')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{totalProjectsCount === 0 ? (
|
||||||
|
<WelcomePageContent />
|
||||||
|
) : visibleProjects.length === 0 ? (
|
||||||
|
<p className="lumiere-empty">{t('no_projects')}</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'lumiere-card-grid',
|
||||||
|
cardScale === 0 && 'lumiere-card-grid--compact',
|
||||||
|
mobileIsL && 'lumiere-card-grid--mobile-1col',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
style={cardScale === 0 || isMobile ? {} : ({ '--lum-card-scale': cardScale } as React.CSSProperties)}
|
||||||
|
>
|
||||||
|
{visibleProjects.map(project =>
|
||||||
|
cardScale === 0 ? (
|
||||||
|
<ProjectCardCompact key={project.id} project={project} />
|
||||||
|
) : (
|
||||||
|
<ProjectCard key={project.id} project={project} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<LoadMore />
|
||||||
|
</main>
|
||||||
|
<Footer {...footerProps} />
|
||||||
|
</div>
|
||||||
|
<CookieBanner />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,10 +17,12 @@ import DefaultNavbar from '@/shared/components/navbar/default-navbar'
|
|||||||
import Footer from '@/shared/components/footer/footer'
|
import Footer from '@/shared/components/footer/footer'
|
||||||
import WelcomePageContent from '@/features/project-list/components/welcome-page-content'
|
import WelcomePageContent from '@/features/project-list/components/welcome-page-content'
|
||||||
import { ProjectListDsNav } from '@/features/project-list/components/project-list-ds-nav'
|
import { ProjectListDsNav } from '@/features/project-list/components/project-list-ds-nav'
|
||||||
|
import { ProjectListLumiere } from '@/features/project-list/components/project-list-lumiere'
|
||||||
import { DsNavStyleProvider } from '@/features/project-list/components/use-is-ds-nav'
|
import { DsNavStyleProvider } from '@/features/project-list/components/use-is-ds-nav'
|
||||||
import CookieBanner from '@/shared/components/cookie-banner'
|
import CookieBanner from '@/shared/components/cookie-banner'
|
||||||
import useThemedPage from '@/shared/hooks/use-themed-page'
|
import useThemedPage from '@/shared/hooks/use-themed-page'
|
||||||
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
|
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
|
||||||
|
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
|
||||||
import { TutorialProvider } from '@/shared/context/tutorial-context'
|
import { TutorialProvider } from '@/shared/context/tutorial-context'
|
||||||
|
|
||||||
function ProjectListRoot() {
|
function ProjectListRoot() {
|
||||||
@@ -80,6 +82,9 @@ function ProjectListPageContent() {
|
|||||||
useThemedPage()
|
useThemedPage()
|
||||||
const { totalProjectsCount, isLoading, loadProgress } =
|
const { totalProjectsCount, isLoading, loadProgress } =
|
||||||
useProjectListContext()
|
useProjectListContext()
|
||||||
|
const {
|
||||||
|
userSettings: { overallTheme },
|
||||||
|
} = useUserSettingsContext()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
eventTracking.sendMB('loads_v2_dash', { page: 'projects' })
|
eventTracking.sendMB('loads_v2_dash', { page: 'projects' })
|
||||||
@@ -95,6 +100,14 @@ function ProjectListPageContent() {
|
|||||||
return loadingComponent
|
return loadingComponent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (overallTheme === 'lumiere-') {
|
||||||
|
return (
|
||||||
|
<DsNavStyleProvider>
|
||||||
|
<ProjectListLumiere />
|
||||||
|
</DsNavStyleProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (totalProjectsCount === 0) {
|
if (totalProjectsCount === 0) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -105,6 +118,7 @@ function ProjectListPageContent() {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DsNavStyleProvider>
|
<DsNavStyleProvider>
|
||||||
<ProjectListDsNav />
|
<ProjectListDsNav />
|
||||||
|
|||||||
+1
-1
@@ -66,7 +66,7 @@ function SidebarDsNav() {
|
|||||||
scrolledUp && 'show-shadow'
|
scrolledUp && 'show-shadow'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SidebarLowerSection showThemeToggle>
|
<SidebarLowerSection showThemeToggle showAccountIcons={false}>
|
||||||
<div className="project-list-sidebar-survey-wrapper">
|
<div className="project-list-sidebar-survey-wrapper">
|
||||||
<SurveyWidgetDsNav />
|
<SurveyWidgetDsNav />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { useTranslation } from 'react-i18next'
|
|||||||
|
|
||||||
const getIcon = (theme: OverallThemeMeta) => {
|
const getIcon = (theme: OverallThemeMeta) => {
|
||||||
switch (theme.val) {
|
switch (theme.val) {
|
||||||
|
case 'lumiere-':
|
||||||
|
return 'auto_awesome'
|
||||||
case 'light-':
|
case 'light-':
|
||||||
return 'light_mode'
|
return 'light_mode'
|
||||||
case 'system':
|
case 'system':
|
||||||
|
|||||||
+4
@@ -98,6 +98,10 @@ function CompileAndDownloadProjectPDFButton({
|
|||||||
const outputFile = data.outputFiles
|
const outputFile = data.outputFiles
|
||||||
.filter((file: { path: string }) => file.path === 'output.pdf')
|
.filter((file: { path: string }) => file.path === 'output.pdf')
|
||||||
.pop()
|
.pop()
|
||||||
|
if (!outputFile) {
|
||||||
|
setShowErrorModal(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
compileGroup: data.compileGroup,
|
compileGroup: data.compileGroup,
|
||||||
|
|||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
import { memo, useCallback, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Project } from '../../../../../../../../types/project/dashboard/api'
|
||||||
|
import {
|
||||||
|
Dropdown,
|
||||||
|
DropdownItem,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownToggle,
|
||||||
|
} from '@/shared/components/dropdown/dropdown-menu'
|
||||||
|
import {
|
||||||
|
OLModal,
|
||||||
|
OLModalBody,
|
||||||
|
OLModalFooter,
|
||||||
|
OLModalHeader,
|
||||||
|
OLModalTitle,
|
||||||
|
} from '@/shared/components/ol/ol-modal'
|
||||||
|
import OLButton from '@/shared/components/ol/ol-button'
|
||||||
|
import OLSpinner from '@/shared/components/ol/ol-spinner'
|
||||||
|
import LoadingSpinner from '@/shared/components/loading-spinner'
|
||||||
|
import MaterialIcon from '@/shared/components/material-icon'
|
||||||
|
|
||||||
|
type ExportFormat = 'html' | 'pdf'
|
||||||
|
|
||||||
|
type DownloadPresentationButtonProps = {
|
||||||
|
project: Project
|
||||||
|
children: (
|
||||||
|
startExport: (format: ExportFormat) => void,
|
||||||
|
exporting: ExportFormat | null
|
||||||
|
) => React.ReactElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadPresentationButton({
|
||||||
|
project,
|
||||||
|
children,
|
||||||
|
}: DownloadPresentationButtonProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [exporting, setExporting] = useState<ExportFormat | null>(null)
|
||||||
|
const [exportError, setExportError] = useState<string | null>(null)
|
||||||
|
const requestIdRef = useRef(0)
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
requestIdRef.current += 1
|
||||||
|
setExporting(null)
|
||||||
|
setExportError(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startExport = useCallback(
|
||||||
|
async (format: ExportFormat) => {
|
||||||
|
const requestId = ++requestIdRef.current
|
||||||
|
setExportError(null)
|
||||||
|
setExporting(format)
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/project/${project.id}/presentation-export/${format}`,
|
||||||
|
{ credentials: 'same-origin' }
|
||||||
|
)
|
||||||
|
if (requestId !== requestIdRef.current) return
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
if (requestId !== requestIdRef.current) return
|
||||||
|
setExporting(null)
|
||||||
|
setExportError(text || `Export failed (HTTP ${response.status})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const blob = await response.blob()
|
||||||
|
if (requestId !== requestIdRef.current) return
|
||||||
|
const disposition = response.headers.get('Content-Disposition')
|
||||||
|
const match = disposition?.match(/filename="?([^"]+)"?/)
|
||||||
|
const filename =
|
||||||
|
match ? match[1] : `presentation.${format === 'pdf' ? 'pdf' : 'html'}`
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
setExporting(null)
|
||||||
|
// Revoke after a delay so the browser has time to initiate the download
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 10000)
|
||||||
|
} catch (err) {
|
||||||
|
if (requestId !== requestIdRef.current) return
|
||||||
|
setExporting(null)
|
||||||
|
setExportError(err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[project.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children(startExport, exporting)}
|
||||||
|
<OLModal
|
||||||
|
show={exporting !== null || exportError !== null}
|
||||||
|
onHide={dismiss}
|
||||||
|
>
|
||||||
|
<OLModalHeader closeButton>
|
||||||
|
<OLModalTitle>
|
||||||
|
{exportError
|
||||||
|
? t('presentation_export_failed')
|
||||||
|
: t('preparing_your_download')}
|
||||||
|
</OLModalTitle>
|
||||||
|
</OLModalHeader>
|
||||||
|
<OLModalBody>
|
||||||
|
{exporting !== null ? (
|
||||||
|
<LoadingSpinner
|
||||||
|
loadingText={t('presentation_export_can_take_a_moment')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
maxHeight: '50vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
marginBottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{exportError}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</OLModalBody>
|
||||||
|
<OLModalFooter>
|
||||||
|
<OLButton variant="secondary" onClick={dismiss}>
|
||||||
|
{t('close')}
|
||||||
|
</OLButton>
|
||||||
|
</OLModalFooter>
|
||||||
|
</OLModal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For ActionsCell (desktop icon buttons row): an icon button that opens a
|
||||||
|
// dropdown with PDF and HTML export choices.
|
||||||
|
const DownloadPresentationButtonTooltip = memo(
|
||||||
|
function DownloadPresentationButtonTooltip({
|
||||||
|
project,
|
||||||
|
}: { project: Project }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (project.compiler !== 'quarto') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DownloadPresentationButton project={project}>
|
||||||
|
{(startExport, exporting) => (
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownToggle
|
||||||
|
id={`download-presentation-toggle-${project.id}`}
|
||||||
|
variant="link"
|
||||||
|
className="action-btn"
|
||||||
|
aria-label={t('download')}
|
||||||
|
>
|
||||||
|
{exporting !== null ? (
|
||||||
|
<OLSpinner size="sm" />
|
||||||
|
) : (
|
||||||
|
<MaterialIcon type="picture_as_pdf" />
|
||||||
|
)}
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu
|
||||||
|
popperConfig={{ strategy: 'fixed' }}
|
||||||
|
renderOnMount
|
||||||
|
flip
|
||||||
|
>
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
onClick={() => startExport('html')}
|
||||||
|
disabled={exporting !== null}
|
||||||
|
leadingIcon="download"
|
||||||
|
>
|
||||||
|
{t('download_as_standalone_html')}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
onClick={() => startExport('pdf')}
|
||||||
|
disabled={exporting !== null}
|
||||||
|
leadingIcon="picture_as_pdf"
|
||||||
|
>
|
||||||
|
{t('download_as_pdf_slides')}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
</DownloadPresentationButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default memo(DownloadPresentationButton)
|
||||||
|
export { DownloadPresentationButtonTooltip }
|
||||||
+9
-1
@@ -8,17 +8,25 @@ import { DownloadProjectButtonTooltip } from './action-buttons/download-project-
|
|||||||
import { LeaveProjectButtonTooltip } from './action-buttons/leave-project-button'
|
import { LeaveProjectButtonTooltip } from './action-buttons/leave-project-button'
|
||||||
import { DeleteProjectButtonTooltip } from './action-buttons/delete-project-button'
|
import { DeleteProjectButtonTooltip } from './action-buttons/delete-project-button'
|
||||||
import { CompileAndDownloadProjectPDFButtonTooltip } from './action-buttons/compile-and-download-project-pdf-button'
|
import { CompileAndDownloadProjectPDFButtonTooltip } from './action-buttons/compile-and-download-project-pdf-button'
|
||||||
|
import { DownloadPresentationButtonTooltip } from './action-buttons/download-presentation-button'
|
||||||
|
|
||||||
type ActionsCellProps = {
|
type ActionsCellProps = {
|
||||||
project: Project
|
project: Project
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isQuartoSlides = (project: Project) =>
|
||||||
|
project.compiler === 'quarto'
|
||||||
|
|
||||||
export default function ActionsCell({ project }: ActionsCellProps) {
|
export default function ActionsCell({ project }: ActionsCellProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CopyProjectButtonTooltip project={project} />
|
<CopyProjectButtonTooltip project={project} />
|
||||||
<DownloadProjectButtonTooltip project={project} />
|
<DownloadProjectButtonTooltip project={project} />
|
||||||
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
|
{isQuartoSlides(project) ? (
|
||||||
|
<DownloadPresentationButtonTooltip project={project} />
|
||||||
|
) : (
|
||||||
|
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
|
||||||
|
)}
|
||||||
<ArchiveProjectButtonTooltip project={project} />
|
<ArchiveProjectButtonTooltip project={project} />
|
||||||
<TrashProjectButtonTooltip project={project} />
|
<TrashProjectButtonTooltip project={project} />
|
||||||
<UnarchiveProjectButtonTooltip project={project} />
|
<UnarchiveProjectButtonTooltip project={project} />
|
||||||
|
|||||||
+2
-4
@@ -28,9 +28,10 @@ function ProjectListTableRow({ project, selected }: ProjectListTableRowProps) {
|
|||||||
</a>{' '}
|
</a>{' '}
|
||||||
<InlineTags className="d-none d-md-inline" projectId={project.id} />
|
<InlineTags className="d-none d-md-inline" projectId={project.id} />
|
||||||
</td>
|
</td>
|
||||||
<td className="dash-cell-date-owner pb-0 d-md-none">
|
<td className="dash-cell-meta d-md-none">
|
||||||
<LastUpdatedCell project={project} />
|
<LastUpdatedCell project={project} />
|
||||||
{ownerName ? <ProjectListOwnerName ownerName={ownerName} /> : null}
|
{ownerName ? <ProjectListOwnerName ownerName={ownerName} /> : null}
|
||||||
|
<InlineTags projectId={project.id} className="ms-1" />
|
||||||
</td>
|
</td>
|
||||||
<td className="dash-cell-format d-none d-md-table-cell">
|
<td className="dash-cell-format d-none d-md-table-cell">
|
||||||
<FormatCell project={project} />
|
<FormatCell project={project} />
|
||||||
@@ -41,9 +42,6 @@ function ProjectListTableRow({ project, selected }: ProjectListTableRowProps) {
|
|||||||
<td className="dash-cell-date d-none d-md-table-cell">
|
<td className="dash-cell-date d-none d-md-table-cell">
|
||||||
<LastUpdatedCell project={project} />
|
<LastUpdatedCell project={project} />
|
||||||
</td>
|
</td>
|
||||||
<td className="dash-cell-tag pt-0 d-md-none">
|
|
||||||
<InlineTags projectId={project.id} />
|
|
||||||
</td>
|
|
||||||
<td className="dash-cell-actions">
|
<td className="dash-cell-actions">
|
||||||
<div className="d-none d-lg-block">
|
<div className="d-none d-lg-block">
|
||||||
<ActionsCell project={project} />
|
<ActionsCell project={project} />
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function WelcomeMessage() {
|
|||||||
{wikiEnabled && (
|
{wikiEnabled && (
|
||||||
<WelcomeMessageLink
|
<WelcomeMessageLink
|
||||||
imgSrc={learnLatexImage}
|
imgSrc={learnLatexImage}
|
||||||
title="Learn LaTeX with a tutorial"
|
title={t('learn_latex_with_a_tutorial')}
|
||||||
href="/learn/latex/Learn_LaTeX_in_30_minutes"
|
href="/learn/latex/Learn_LaTeX_in_30_minutes"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
/>
|
/>
|
||||||
@@ -38,7 +38,7 @@ export default function WelcomeMessage() {
|
|||||||
{templatesEnabled && (
|
{templatesEnabled && (
|
||||||
<WelcomeMessageLink
|
<WelcomeMessageLink
|
||||||
imgSrc={browseTemplatesImage}
|
imgSrc={browseTemplatesImage}
|
||||||
title="Browse templates"
|
title={t('browse_templates')}
|
||||||
href="/templates"
|
href="/templates"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+14
-6
@@ -7,6 +7,7 @@ import { usePermissionsContext } from '@/features/ide-react/context/permissions-
|
|||||||
import { ProjectCompiler } from '@ol-types/project-settings'
|
import { ProjectCompiler } from '@ol-types/project-settings'
|
||||||
import { useSetCompilationSettingWithEvent } from '@/features/editor-left-menu/hooks/use-set-compilation-setting'
|
import { useSetCompilationSettingWithEvent } from '@/features/editor-left-menu/hooks/use-set-compilation-setting'
|
||||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||||
|
import { findInTree } from '@/features/file-tree/util/find-in-tree'
|
||||||
|
|
||||||
// Which compiler engines make sense for a given root-file extension. CLSI
|
// Which compiler engines make sense for a given root-file extension. CLSI
|
||||||
// dispatches the real engine from this extension (.qmd → Quarto, .typ → Typst,
|
// dispatches the real engine from this extension (.qmd → Quarto, .typ → Typst,
|
||||||
@@ -34,7 +35,7 @@ export default function CompilerSetting() {
|
|||||||
const [compilerOptions] = useState(() => getCompilerOptions())
|
const [compilerOptions] = useState(() => getCompilerOptions())
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { write } = usePermissionsContext()
|
const { write } = usePermissionsContext()
|
||||||
const { docs } = useFileTreeData()
|
const { docs, fileTreeData } = useFileTreeData()
|
||||||
const changeCompiler = useSetCompilationSettingWithEvent(
|
const changeCompiler = useSetCompilationSettingWithEvent(
|
||||||
'compiler',
|
'compiler',
|
||||||
setCompiler
|
setCompiler
|
||||||
@@ -43,14 +44,21 @@ export default function CompilerSetting() {
|
|||||||
// Disable the engines that don't apply to the current root file's extension.
|
// Disable the engines that don't apply to the current root file's extension.
|
||||||
// The currently-selected engine is always left enabled so it keeps showing.
|
// The currently-selected engine is always left enabled so it keeps showing.
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
const rootDoc = rootDocId
|
let rootDocName: string | undefined
|
||||||
? docs?.find(doc => doc.doc.id === rootDocId)
|
if (rootDocId) {
|
||||||
: undefined
|
const fromDocs = docs?.find(doc => doc.doc.id === rootDocId)
|
||||||
const extension = rootDoc?.doc.name.split('.').pop()?.toLowerCase()
|
if (fromDocs) {
|
||||||
|
rootDocName = fromDocs.doc.name
|
||||||
|
} else if (fileTreeData) {
|
||||||
|
// Fallback: look up directly in tree in case of ID format mismatch
|
||||||
|
const found = findInTree(fileTreeData, rootDocId)
|
||||||
|
if (found?.type === 'doc') rootDocName = found.entity.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const extension = rootDocName?.split('.').pop()?.toLowerCase()
|
||||||
const allowed = extension ? ENGINES_BY_EXTENSION[extension] : undefined
|
const allowed = extension ? ENGINES_BY_EXTENSION[extension] : undefined
|
||||||
|
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
// Unknown / no root file: don't restrict anything.
|
|
||||||
return compilerOptions
|
return compilerOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import { useCallback, useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
import Setting from '../setting'
|
||||||
|
import OLFormSelect from '@/shared/components/ol/ol-form-select'
|
||||||
|
|
||||||
|
export default function InterfaceLanguageSetting() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const currentLangCode = getMeta('ol-i18n').currentLangCode
|
||||||
|
const footerMeta = getMeta('ol-footer')
|
||||||
|
const availableLanguages: string[] = useMemo(
|
||||||
|
() => footerMeta?.availableLanguages ?? [],
|
||||||
|
[footerMeta]
|
||||||
|
)
|
||||||
|
const translatedLanguages: Record<string, string> = useMemo(
|
||||||
|
() => footerMeta?.translatedLanguages ?? {},
|
||||||
|
[footerMeta]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const lng = event.target.value
|
||||||
|
const returnTo = encodeURIComponent(window.location.pathname)
|
||||||
|
window.location.href = `/set-language?lng=${encodeURIComponent(lng)}&return_to=${returnTo}`
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (availableLanguages.length <= 1) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Setting controlId="interfaceLanguage" label={t('interface_language')}>
|
||||||
|
<OLFormSelect
|
||||||
|
id="interfaceLanguage"
|
||||||
|
className="ide-dropdown-setting ide-dropdown-setting-wide"
|
||||||
|
size="sm"
|
||||||
|
value={currentLangCode}
|
||||||
|
onChange={handleChange}
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
|
{availableLanguages
|
||||||
|
.filter(lng => translatedLanguages[lng])
|
||||||
|
.map(lng => (
|
||||||
|
<option key={lng} value={lng}>
|
||||||
|
{translatedLanguages[lng]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</OLFormSelect>
|
||||||
|
</Setting>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import PDFViewerSetting from '@/features/settings/components/editor-settings/pdf
|
|||||||
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
||||||
import SpellCheckSetting from '@/features/settings/components/editor-settings/spell-check-setting'
|
import SpellCheckSetting from '@/features/settings/components/editor-settings/spell-check-setting'
|
||||||
import DictionarySetting from '@/features/settings/components/editor-settings/dictionary-setting'
|
import DictionarySetting from '@/features/settings/components/editor-settings/dictionary-setting'
|
||||||
|
import InterfaceLanguageSetting from '@/features/settings/components/editor-settings/interface-language-setting'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import BreadcrumbsSetting from '@/features/settings/components/editor-settings/breadcrumbs-setting'
|
import BreadcrumbsSetting from '@/features/settings/components/editor-settings/breadcrumbs-setting'
|
||||||
import NonBlinkingCursorSetting from '@/features/settings/components/editor-settings/non-blinking-cursor-setting'
|
import NonBlinkingCursorSetting from '@/features/settings/components/editor-settings/non-blinking-cursor-setting'
|
||||||
@@ -165,6 +166,10 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
|
|||||||
key: 'dictionary-settings',
|
key: 'dictionary-settings',
|
||||||
component: <DictionarySetting />,
|
component: <DictionarySetting />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'interfaceLanguage',
|
||||||
|
component: <InterfaceLanguageSetting />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
...spellcheckExtraSections,
|
...spellcheckExtraSections,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { EditorSelection } from '@codemirror/state'
|
||||||
|
import { EditorView } from '@codemirror/view'
|
||||||
|
import { syntaxTree } from '@codemirror/language'
|
||||||
|
import { matchingAncestor } from '../utils/tree-operations/ancestors'
|
||||||
|
import { type TypstFormattingNode } from '../utils/tree-operations/formatting'
|
||||||
|
import { wrapRanges } from './ranges'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle Typst symmetric markup (e.g. *bold* or _italic_).
|
||||||
|
*
|
||||||
|
* If the cursor/selection is already inside a matching syntax node the
|
||||||
|
* surrounding markers are removed; otherwise the selection is wrapped.
|
||||||
|
*/
|
||||||
|
export const toggleTypstMarkup =
|
||||||
|
(marker: string, nodeTypeName: TypstFormattingNode) =>
|
||||||
|
(view: EditorView): boolean => {
|
||||||
|
if (view.state.readOnly) return false
|
||||||
|
|
||||||
|
view.dispatch(
|
||||||
|
view.state.changeByRange(range => {
|
||||||
|
const tree = syntaxTree(view.state)
|
||||||
|
const node = tree.resolveInner(range.from, -1)
|
||||||
|
const formattingNode = matchingAncestor(
|
||||||
|
node,
|
||||||
|
n => n.type.name === nodeTypeName
|
||||||
|
)
|
||||||
|
|
||||||
|
if (formattingNode) {
|
||||||
|
const mLen = marker.length
|
||||||
|
const fFrom = formattingNode.from
|
||||||
|
const fTo = formattingNode.to
|
||||||
|
|
||||||
|
// Adjust the selection for the two deletions applied simultaneously.
|
||||||
|
// Positions after fFrom shift left by mLen (opening deletion).
|
||||||
|
// Positions at or after fTo shift left by another mLen (closing deletion).
|
||||||
|
const newFrom =
|
||||||
|
range.from > fFrom ? range.from - mLen : range.from
|
||||||
|
const newTo =
|
||||||
|
range.to -
|
||||||
|
(range.to > fFrom ? mLen : 0) -
|
||||||
|
(range.to >= fTo ? mLen : 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
changes: [
|
||||||
|
{ from: fFrom, to: fFrom + mLen, insert: '' },
|
||||||
|
{ from: fTo - mLen, to: fTo, insert: '' },
|
||||||
|
],
|
||||||
|
range: range.empty
|
||||||
|
? EditorSelection.cursor(newFrom)
|
||||||
|
: EditorSelection.range(newFrom, newTo),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap: insert markers around the selection
|
||||||
|
const content = view.state.sliceDoc(range.from, range.to)
|
||||||
|
return {
|
||||||
|
changes: {
|
||||||
|
from: range.from,
|
||||||
|
to: range.to,
|
||||||
|
insert: `${marker}${content}${marker}`,
|
||||||
|
},
|
||||||
|
range: range.empty
|
||||||
|
? EditorSelection.cursor(range.from + marker.length)
|
||||||
|
: EditorSelection.range(
|
||||||
|
range.from + marker.length,
|
||||||
|
range.to + marker.length
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ scrollIntoView: true }
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wraps selection in #link("")[…] and places the cursor in the URL field.
|
||||||
|
// Prefix breakdown: # l i n k ( " " ) [ = 10 chars; URL slot is at offset 7.
|
||||||
|
export const wrapTypstLink = wrapRanges(
|
||||||
|
'#link("")[',
|
||||||
|
']',
|
||||||
|
false,
|
||||||
|
range => EditorSelection.cursor(range.from - 3)
|
||||||
|
)
|
||||||
@@ -12,7 +12,7 @@ function SwitchToPDFButton() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pdfLayout === 'sideBySide') {
|
if (pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+60
-1
@@ -12,7 +12,10 @@ import { MathDropdown } from './math-dropdown'
|
|||||||
import { InsertListDropdown } from './insert-list-dropdown'
|
import { InsertListDropdown } from './insert-list-dropdown'
|
||||||
import { TableDropdown } from './table-dropdown'
|
import { TableDropdown } from './table-dropdown'
|
||||||
import { LegacyTableDropdown } from './table-inserter-dropdown-legacy'
|
import { LegacyTableDropdown } from './table-inserter-dropdown-legacy'
|
||||||
import { withinFormattingCommand } from '@/features/source-editor/utils/tree-operations/formatting'
|
import {
|
||||||
|
withinFormattingCommand,
|
||||||
|
withinTypstFormatting,
|
||||||
|
} from '@/features/source-editor/utils/tree-operations/formatting'
|
||||||
import { isMac } from '@/shared/utils/os'
|
import { isMac } from '@/shared/utils/os'
|
||||||
import { useProjectContext } from '@/shared/context/project-context'
|
import { useProjectContext } from '@/shared/context/project-context'
|
||||||
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
|
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
|
||||||
@@ -41,6 +44,7 @@ export const ToolbarItems: FC<{
|
|||||||
const { features } = useProjectContext()
|
const { features } = useProjectContext()
|
||||||
const permissions = usePermissionsContext()
|
const permissions = usePermissionsContext()
|
||||||
const isActive = withinFormattingCommand(state)
|
const isActive = withinFormattingCommand(state)
|
||||||
|
const isTypstActive = withinTypstFormatting(state)
|
||||||
|
|
||||||
const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
|
const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
|
||||||
const showGroup = (group: string) => !overflowed || overflowed.has(group)
|
const showGroup = (group: string) => !overflowed || overflowed.has(group)
|
||||||
@@ -189,6 +193,61 @@ export const ToolbarItems: FC<{
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{languageName === 'typst' && (
|
||||||
|
<>
|
||||||
|
{showGroup('group-format') && (
|
||||||
|
<div
|
||||||
|
className="ol-cm-toolbar-button-group"
|
||||||
|
aria-label={t('toolbar_text_style')}
|
||||||
|
>
|
||||||
|
<ToolbarButton
|
||||||
|
id="toolbar-format-bold"
|
||||||
|
label={t('toolbar_bold')}
|
||||||
|
command={commands.toggleTypstBold}
|
||||||
|
active={isTypstActive('Strong')}
|
||||||
|
icon="format_bold"
|
||||||
|
shortcut={isMac ? '⌘B' : 'Ctrl+B'}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
id="toolbar-format-italic"
|
||||||
|
label={t('toolbar_italic')}
|
||||||
|
command={commands.toggleTypstItalic}
|
||||||
|
active={isTypstActive('Emphasis')}
|
||||||
|
icon="format_italic"
|
||||||
|
shortcut={isMac ? '⌘I' : 'Ctrl+I'}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
id="toolbar-format-underline"
|
||||||
|
label={t('toolbar_underline')}
|
||||||
|
command={commands.wrapTypstUnderline}
|
||||||
|
icon="format_underlined"
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
id="toolbar-format-smallcaps"
|
||||||
|
label={t('toolbar_smallcaps')}
|
||||||
|
command={commands.wrapTypstSmallcaps}
|
||||||
|
icon="Sc"
|
||||||
|
textIcon
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showGroup('group-misc') && (
|
||||||
|
<div
|
||||||
|
className="ol-cm-toolbar-button-group"
|
||||||
|
data-overflow="group-misc"
|
||||||
|
aria-label={t('toolbar_insert_misc')}
|
||||||
|
>
|
||||||
|
<ToolbarButton
|
||||||
|
id="toolbar-typst-href"
|
||||||
|
label={t('toolbar_insert_link')}
|
||||||
|
command={commands.wrapTypstLink}
|
||||||
|
icon="add_link"
|
||||||
|
shortcut={isMac ? '⌘K' : 'Ctrl+K'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
} from '@codemirror/search'
|
} from '@codemirror/search'
|
||||||
import { sendMB } from '@/infrastructure/event-tracking'
|
import { sendMB } from '@/infrastructure/event-tracking'
|
||||||
import { toggleRanges, wrapRanges } from '../../commands/ranges'
|
import { toggleRanges, wrapRanges } from '../../commands/ranges'
|
||||||
|
import {
|
||||||
|
toggleTypstMarkup,
|
||||||
|
wrapTypstLink,
|
||||||
|
} from '../../commands/typst-ranges'
|
||||||
import {
|
import {
|
||||||
ancestorListType,
|
ancestorListType,
|
||||||
toggleListForRanges,
|
toggleListForRanges,
|
||||||
@@ -25,6 +29,11 @@ import { sendSearchEvent } from '@/features/event-tracking/search-events'
|
|||||||
|
|
||||||
export const toggleBold = toggleRanges('\\textbf')
|
export const toggleBold = toggleRanges('\\textbf')
|
||||||
export const toggleItalic = toggleRanges('\\textit')
|
export const toggleItalic = toggleRanges('\\textit')
|
||||||
|
export const toggleTypstBold = toggleTypstMarkup('*', 'Strong')
|
||||||
|
export const toggleTypstItalic = toggleTypstMarkup('_', 'Emphasis')
|
||||||
|
export const wrapTypstUnderline = wrapRanges('#underline[', ']')
|
||||||
|
export const wrapTypstSmallcaps = wrapRanges('#smallcaps[', ']')
|
||||||
|
export { wrapTypstLink }
|
||||||
|
|
||||||
// TODO: apply as a snippet?
|
// TODO: apply as a snippet?
|
||||||
// TODO: read URL from clipboard?
|
// TODO: read URL from clipboard?
|
||||||
|
|||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
} from '@codemirror/view'
|
||||||
|
import { EditorState, Range } from '@codemirror/state'
|
||||||
|
import { syntaxTree } from '@codemirror/language'
|
||||||
|
import { Tree } from '@lezer/common'
|
||||||
|
import { selectionIntersects } from './selection'
|
||||||
|
|
||||||
|
function shouldDecorate(
|
||||||
|
state: EditorState,
|
||||||
|
from: number,
|
||||||
|
to: number
|
||||||
|
): boolean {
|
||||||
|
return state.readOnly || !selectionIntersects(state.selection, { from, to })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ATX_HEADING_LEVEL: Record<string, string> = {
|
||||||
|
ATXHeading1: '1',
|
||||||
|
ATXHeading2: '2',
|
||||||
|
ATXHeading3: '3',
|
||||||
|
ATXHeading4: '4',
|
||||||
|
ATXHeading5: '5',
|
||||||
|
ATXHeading6: '6',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const quartoDecorations = ViewPlugin.define(
|
||||||
|
view => {
|
||||||
|
const createDecorations = (
|
||||||
|
state: EditorState,
|
||||||
|
tree: Tree
|
||||||
|
): DecorationSet => {
|
||||||
|
const decorations: Range<Decoration>[] = []
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
tree.iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter(nodeRef) {
|
||||||
|
const { from: nFrom, to: nTo } = nodeRef
|
||||||
|
const level = ATX_HEADING_LEVEL[nodeRef.type.name]
|
||||||
|
|
||||||
|
// ── ATX headings: # Title, ## Title, … ─────────────────────────
|
||||||
|
if (level !== undefined) {
|
||||||
|
const mark = nodeRef.node.getChild('HeaderMark')
|
||||||
|
if (mark) {
|
||||||
|
const cls = `ol-cm-md-heading ol-cm-md-h${level}`
|
||||||
|
if (shouldDecorate(state, nFrom, nTo)) {
|
||||||
|
// Hide the `# ` prefix (mark + optional trailing space)
|
||||||
|
const afterMark = state.doc.sliceString(mark.to, mark.to + 1)
|
||||||
|
const contentFrom =
|
||||||
|
afterMark === ' ' ? mark.to + 1 : mark.to
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(nFrom, contentFrom)
|
||||||
|
)
|
||||||
|
if (contentFrom < nTo) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({ class: cls }).range(contentFrom, nTo)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (nodeRef.type.name) {
|
||||||
|
// ── Bold: **text** or __text__ ─────────────────────────────────
|
||||||
|
case 'StrongEmphasis': {
|
||||||
|
if (shouldDecorate(state, nFrom, nTo)) {
|
||||||
|
const marks = nodeRef.node.getChildren('EmphasisMark')
|
||||||
|
const first = marks[0]
|
||||||
|
const last = marks[marks.length - 1]
|
||||||
|
if (first && last && first !== last) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(first.from, first.to)
|
||||||
|
)
|
||||||
|
if (last.from > first.to) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: 'ol-cm-md-strong',
|
||||||
|
inclusive: true,
|
||||||
|
}).range(first.to, last.from)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(last.from, last.to)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Italic: *text* or _text_ ────────────────────────────────────
|
||||||
|
case 'Emphasis': {
|
||||||
|
if (shouldDecorate(state, nFrom, nTo)) {
|
||||||
|
const marks = nodeRef.node.getChildren('EmphasisMark')
|
||||||
|
const first = marks[0]
|
||||||
|
const last = marks[marks.length - 1]
|
||||||
|
if (first && last && first !== last) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(first.from, first.to)
|
||||||
|
)
|
||||||
|
if (last.from > first.to) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: 'ol-cm-md-emph',
|
||||||
|
inclusive: true,
|
||||||
|
}).range(first.to, last.from)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(last.from, last.to)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Strikethrough: ~~text~~ ─────────────────────────────────────
|
||||||
|
case 'Strikethrough': {
|
||||||
|
if (shouldDecorate(state, nFrom, nTo)) {
|
||||||
|
const marks = nodeRef.node.getChildren('StrikethroughMark')
|
||||||
|
const first = marks[0]
|
||||||
|
const last = marks[marks.length - 1]
|
||||||
|
if (first && last && first !== last) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(first.from, first.to)
|
||||||
|
)
|
||||||
|
if (last.from > first.to) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: 'ol-cm-md-strikethrough',
|
||||||
|
inclusive: true,
|
||||||
|
}).range(first.to, last.from)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(last.from, last.to)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline code: `code` ─────────────────────────────────────────
|
||||||
|
case 'InlineCode': {
|
||||||
|
if (shouldDecorate(state, nFrom, nTo)) {
|
||||||
|
const marks = nodeRef.node.getChildren('CodeMark')
|
||||||
|
const first = marks[0]
|
||||||
|
const last = marks[marks.length - 1]
|
||||||
|
if (first && last && first !== last) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(first.from, first.to)
|
||||||
|
)
|
||||||
|
if (last.from > first.to) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: 'ol-cm-md-inline-code',
|
||||||
|
}).range(first.to, last.from)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(last.from, last.to)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Decoration.set(decorations, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousTree = syntaxTree(view.state)
|
||||||
|
|
||||||
|
return {
|
||||||
|
decorations: createDecorations(view.state, previousTree),
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const tree = syntaxTree(update.state)
|
||||||
|
if (
|
||||||
|
tree.type === previousTree.type &&
|
||||||
|
tree.length < update.view.viewport.to
|
||||||
|
) {
|
||||||
|
this.decorations = this.decorations.map(update.changes)
|
||||||
|
} else if (
|
||||||
|
tree !== previousTree ||
|
||||||
|
update.viewportChanged ||
|
||||||
|
update.selectionSet
|
||||||
|
) {
|
||||||
|
previousTree = tree
|
||||||
|
this.decorations = createDecorations(update.state, tree)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations(value) {
|
||||||
|
return value.decorations
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
} from '@codemirror/view'
|
||||||
|
import { EditorState, Range } from '@codemirror/state'
|
||||||
|
import { syntaxTree } from '@codemirror/language'
|
||||||
|
import { Tree } from '@lezer/common'
|
||||||
|
import { selectionIntersects } from './selection'
|
||||||
|
|
||||||
|
function shouldDecorate(
|
||||||
|
state: EditorState,
|
||||||
|
from: number,
|
||||||
|
to: number
|
||||||
|
): boolean {
|
||||||
|
return state.readOnly || !selectionIntersects(state.selection, { from, to })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const typstDecorations = ViewPlugin.define(
|
||||||
|
view => {
|
||||||
|
const createDecorations = (
|
||||||
|
state: EditorState,
|
||||||
|
tree: Tree
|
||||||
|
): DecorationSet => {
|
||||||
|
const decorations: Range<Decoration>[] = []
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
tree.iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter(nodeRef) {
|
||||||
|
const { from: nFrom, to: nTo } = nodeRef
|
||||||
|
|
||||||
|
switch (nodeRef.type.name) {
|
||||||
|
case 'Strong': {
|
||||||
|
// *bold text*
|
||||||
|
if (shouldDecorate(state, nFrom, nTo)) {
|
||||||
|
decorations.push(Decoration.replace({}).range(nFrom, nFrom + 1))
|
||||||
|
const body = nodeRef.node.getChild('StrongBody')
|
||||||
|
if (body) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: 'ol-cm-typst-strong',
|
||||||
|
inclusive: true,
|
||||||
|
}).range(body.from, body.to)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (nTo > nFrom + 1) {
|
||||||
|
decorations.push(Decoration.replace({}).range(nTo - 1, nTo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Emphasis': {
|
||||||
|
// _italic text_
|
||||||
|
if (shouldDecorate(state, nFrom, nTo)) {
|
||||||
|
decorations.push(Decoration.replace({}).range(nFrom, nFrom + 1))
|
||||||
|
const body = nodeRef.node.getChild('EmphBody')
|
||||||
|
if (body) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: 'ol-cm-typst-emph',
|
||||||
|
inclusive: true,
|
||||||
|
}).range(body.from, body.to)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (nTo > nFrom + 1) {
|
||||||
|
decorations.push(Decoration.replace({}).range(nTo - 1, nTo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Heading': {
|
||||||
|
// = Title / == Title / === Title …
|
||||||
|
const markNode = nodeRef.node.getChild('HeadingMark')
|
||||||
|
const titleNode = nodeRef.node.getChild('HeadingTitle')
|
||||||
|
if (markNode && titleNode) {
|
||||||
|
const markText = state.doc.sliceString(
|
||||||
|
markNode.from,
|
||||||
|
markNode.to
|
||||||
|
)
|
||||||
|
const level = (markText.match(/^=+/) ?? ['='])[0].length
|
||||||
|
const cls = `ol-cm-typst-heading ol-cm-typst-h${Math.min(level, 6)}`
|
||||||
|
|
||||||
|
if (shouldDecorate(state, markNode.from, markNode.to)) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(markNode.from, markNode.to)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({ class: cls }).range(
|
||||||
|
titleNode.from,
|
||||||
|
titleNode.to
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'RawInline': {
|
||||||
|
// `inline code`
|
||||||
|
if (shouldDecorate(state, nFrom, nTo)) {
|
||||||
|
const content = nodeRef.node.getChild('RawInlineContent')
|
||||||
|
if (content) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(nFrom, content.from)
|
||||||
|
)
|
||||||
|
decorations.push(
|
||||||
|
Decoration.mark({
|
||||||
|
class: 'ol-cm-typst-raw-inline',
|
||||||
|
}).range(content.from, content.to)
|
||||||
|
)
|
||||||
|
decorations.push(
|
||||||
|
Decoration.replace({}).range(content.to, nTo)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Decoration.set(decorations, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousTree = syntaxTree(view.state)
|
||||||
|
|
||||||
|
return {
|
||||||
|
decorations: createDecorations(view.state, previousTree),
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const tree = syntaxTree(update.state)
|
||||||
|
if (
|
||||||
|
tree.type === previousTree.type &&
|
||||||
|
tree.length < update.view.viewport.to
|
||||||
|
) {
|
||||||
|
// still parsing — just map existing decorations over any edits
|
||||||
|
this.decorations = this.decorations.map(update.changes)
|
||||||
|
} else if (
|
||||||
|
tree !== previousTree ||
|
||||||
|
update.viewportChanged ||
|
||||||
|
update.selectionSet // ← re-evaluate on cursor movement to show/hide markers
|
||||||
|
) {
|
||||||
|
previousTree = tree
|
||||||
|
this.decorations = createDecorations(update.state, tree)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations(value) {
|
||||||
|
return value.decorations
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -467,6 +467,63 @@ const mainVisualTheme = EditorView.theme({
|
|||||||
padding: '0 0.5em',
|
padding: '0 0.5em',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Quarto / Markdown visual decorations ─────────────────────────────────
|
||||||
|
'.ol-cm-md-strong': {
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
'.ol-cm-md-emph': {
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
'.ol-cm-md-strikethrough': {
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
},
|
||||||
|
'.ol-cm-md-inline-code': {
|
||||||
|
fontFamily: 'var(--source-font-family)',
|
||||||
|
fontSize: '0.9em',
|
||||||
|
backgroundColor: 'rgba(125, 125, 125, 0.1)',
|
||||||
|
borderRadius: '3px',
|
||||||
|
lineHeight: 1,
|
||||||
|
},
|
||||||
|
'.ol-cm-md-heading': {
|
||||||
|
fontWeight: 550,
|
||||||
|
lineHeight: '1.35',
|
||||||
|
color: 'inherit !important',
|
||||||
|
background: 'inherit !important',
|
||||||
|
},
|
||||||
|
'.ol-cm-md-h1': { fontSize: '2em' },
|
||||||
|
'.ol-cm-md-h2': { fontSize: '1.6em' },
|
||||||
|
'.ol-cm-md-h3': { fontSize: '1.44em' },
|
||||||
|
'.ol-cm-md-h4': { fontSize: '1.2em' },
|
||||||
|
'.ol-cm-md-h5': { fontSize: '1em' },
|
||||||
|
'.ol-cm-md-h6': { fontSize: '1em' },
|
||||||
|
|
||||||
|
// ── Typst visual decorations ──────────────────────────────────────────────
|
||||||
|
'.ol-cm-typst-strong': {
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
'.ol-cm-typst-emph': {
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
'.ol-cm-typst-heading': {
|
||||||
|
fontWeight: 550,
|
||||||
|
lineHeight: '1.35',
|
||||||
|
color: 'inherit !important',
|
||||||
|
background: 'inherit !important',
|
||||||
|
},
|
||||||
|
'.ol-cm-typst-h1': { fontSize: '2em' },
|
||||||
|
'.ol-cm-typst-h2': { fontSize: '1.6em' },
|
||||||
|
'.ol-cm-typst-h3': { fontSize: '1.44em' },
|
||||||
|
'.ol-cm-typst-h4': { fontSize: '1.2em' },
|
||||||
|
'.ol-cm-typst-h5': { fontSize: '1em' },
|
||||||
|
'.ol-cm-typst-h6': { fontSize: '1em' },
|
||||||
|
'.ol-cm-typst-raw-inline': {
|
||||||
|
fontFamily: 'var(--source-font-family)',
|
||||||
|
fontSize: '0.9em',
|
||||||
|
backgroundColor: 'rgba(125, 125, 125, 0.1)',
|
||||||
|
borderRadius: '3px',
|
||||||
|
lineHeight: 1,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const contentWidthThemeConf = new Compartment()
|
const contentWidthThemeConf = new Compartment()
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { listItemMarker } from './list-item-marker'
|
|||||||
import { pasteHtml } from './paste-html'
|
import { pasteHtml } from './paste-html'
|
||||||
import { commandTooltip } from '../command-tooltip'
|
import { commandTooltip } from '../command-tooltip'
|
||||||
import { tableGeneratorTheme } from './table-generator'
|
import { tableGeneratorTheme } from './table-generator'
|
||||||
|
import { typstDecorations } from './typst-decorations'
|
||||||
|
import { quartoDecorations } from './quarto-decorations'
|
||||||
import { debugConsole } from '@/utils/debugging'
|
import { debugConsole } from '@/utils/debugging'
|
||||||
import { PreviewPath } from '../../../../../../types/preview-path'
|
import { PreviewPath } from '../../../../../../types/preview-path'
|
||||||
|
|
||||||
@@ -183,6 +185,8 @@ const extension = (options: Options) => [
|
|||||||
atomicDecorations(options),
|
atomicDecorations(options),
|
||||||
visualEditorExtensions.map(extension => extension(options)),
|
visualEditorExtensions.map(extension => extension(options)),
|
||||||
markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets
|
markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets
|
||||||
|
typstDecorations,
|
||||||
|
quartoDecorations,
|
||||||
visualKeymap,
|
visualKeymap,
|
||||||
commandTooltip,
|
commandTooltip,
|
||||||
scrollJumpAdjuster,
|
scrollJumpAdjuster,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user