diff --git a/Dockerfile b/Dockerfile index d5c7b1b..65cd678 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ # Build Frontend -FROM node:20-alpine AS frontend-builder +FROM oven/bun:alpine AS frontend-builder WORKDIR /app -COPY package*.json ./ -RUN npm i +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile COPY . . -RUN npm run build +RUN bun run build # Build Backend FROM rust:alpine AS backend-builder @@ -19,7 +19,8 @@ RUN cargo build --release # Final Runtime Image FROM alpine:3.19 WORKDIR /app -RUN apk add --no-cache libgcc openssl pandoc +RUN apk add --no-cache libgcc openssl pandoc curl +RUN curl -L https://github.com/Myriad-Dreamin/tinymist/releases/latest/download/tinymist-alpine-x64 -o /usr/local/bin/tinymist && chmod +x /usr/local/bin/tinymist COPY --from=frontend-builder /app/build /app/build COPY --from=backend-builder /app/server/target/release/server /app/server ENV PORT=3000 diff --git a/README.md b/README.md index 140284e..cff368e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TypstDrive -[![Version](https://img.shields.io/badge/version-1.2.0-blue.svg)](https://github.com/your-username/typstdrive) +[![Version](https://img.shields.io/badge/version-1.3.0-blue.svg)](https://github.com/your-username/typstdrive) [![Typst Version](https://img.shields.io/badge/Typst-0.14.2-239dad?logo=typst&logoColor=white)](https://typst.app/) [![Rust](https://img.shields.io/badge/Rust-1.82+-orange?logo=rust&logoColor=white)](https://www.rust-lang.org/) [![SvelteKit](https://img.shields.io/badge/SvelteKit-5-ff3e00?logo=svelte)](https://kit.svelte.dev/) @@ -27,17 +27,21 @@ TypstDrive allows you to upload custom `.ttf` or `.otf` fonts and image files (` ### Custom Fonts -When you upload a font file (e.g., `JetBrainsMono-Regular.ttf`), it is automatically made available to the Typst compiler. You can use the font in two ways: +When you upload a font file (e.g., `JetBrainsMono-Regular.ttf`), it is automatically made available to the Typst compiler and the intelligent `tinymist` Language Server. TypstDrive extracts the true typographic family name embedded inside the font file and auto-populates it in your document and dropdowns. -1. **By Typographic Family Name:** You can use the internal font family name embedded in the file. +You can use the font in two ways: + +1. **By Typographic Family Name:** This is extracted automatically when you upload the font. ```typst #set text(font: "JetBrains Mono") ``` -2. **By Filename (Convenience Alias):** You can also use the exact name of the uploaded file (without the extension), which is extremely helpful if you are unsure of the exact typographic family name. +2. **By Filename (Convenience Alias):** You can also use the exact name of the uploaded file (without the extension). ```typst #set text(font: "JetBrainsMono-Regular") ``` +*Note: You do not need to refresh the page after uploading a font. The LSP server will automatically restart and detect your newly uploaded font, providing instant autocompletion and removing any "Unknown Font Family" warnings!* + ### Images Uploaded images can be referenced natively using the `#image` function in Typst. Simply upload your image file (e.g., `logo.png`) to your dashboard and reference it by its exact filename in your `.typ` document. @@ -77,9 +81,13 @@ TypstDrive is completely self-hostable. We provide a Docker image that packages The PostgreSQL database containing users and documents is persisted via the Docker volume `pgdata`. This is automatically configured in `docker-compose.yml` to ensure your data persists across container restarts. -## Local Development +## Contributing & Local Development -If you'd like to contribute or run TypstDrive without Docker: +If you'd like to contribute or run TypstDrive without Docker, you must first clone the Typst compiler repository into the `typst` folder for testing and building the backend: + +```bash +git clone https://github.com/typst/typst.git typst +``` ### Frontend 1. Install dependencies: `npm install` @@ -92,6 +100,19 @@ If you'd like to contribute or run TypstDrive without Docker: Note: The frontend expects the backend to be running on port 3000. During local development via Vite, API calls are proxied automatically. +## Roadmap + +- [ ] Add folder-level sharing and permissions +- [ ] Add Project Spaces (Projects have multiple files and typst.toml) +- [ ] Add Importing Typst Templates from Typst +- [ ] Improve mobile-responsive editing experience + + +Your operational mode has changed from plan to build. +You are no longer in read-only mode. +You are permitted to make file changes, run shell commands, and utilize your arsenal of tools as needed. + + ## Screenshots

diff --git a/docker-compose.yml b/docker-compose.yml index f839cb9..bdb5163 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: db: image: postgres:16-alpine @@ -8,7 +6,7 @@ services: POSTGRES_PASSWORD: password POSTGRES_DB: typstdrive ports: - - "5432:5432" + - "5433:5432" volumes: - pgdata:/var/lib/postgresql/data diff --git a/package.json b/package.json index e579af1..3f853b2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "typstdrive", "private": true, - "version": "1.2.0", + "version": "1.3.0", "type": "module", "scripts": { "dev": "vite dev --host", @@ -23,13 +23,16 @@ "svelte-check": "^4.4.6", "tailwindcss": "^4.2.2", "typescript": "^5.9.3", - "vite": "^7.3.1", + "vite": "^7.3.2", "vite-plugin-top-level-await": "^1.6.0", "vite-plugin-wasm": "^3.6.0" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lint": "^6.9.5", + "@codemirror/lsp-client": "^6.2.2", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.41.0", "@iconify/svelte": "^5.2.1", diff --git a/server/Cargo.toml b/server/Cargo.toml index 45bb435..a45d1db 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "server" -version = "1.2.0" +version = "1.3.0" edition = "2021" [dependencies] @@ -21,13 +21,15 @@ futures-util = "0.3" ecow = "0.2" typst = { version = "0.14.2", path = "../typst/crates/typst" } -typst-kit = { path = "../typst/crates/typst-kit", features = ["downloads", "packages", "embed-fonts"] } +typst-kit = { path = "../typst/crates/typst-kit", features = ["system-downloader", "system-packages", "embedded-fonts"] } typst-pdf = { path = "../typst/crates/typst-pdf" } typst-render = { path = "../typst/crates/typst-render" } typst-svg = { path = "../typst/crates/typst-svg" } +typst-layout = { path = "../typst/crates/typst-layout" } yrs = "0.18.8" yrs-axum = "0.8" -typst-assets = "0.14.2" +typst-assets = { version = "0.14.2", features = ["fonts"] } tokio-stream = "0.1.18" +tempfile = "3.27.0" diff --git a/server/src/compiler.rs b/server/src/compiler.rs index 116ec74..4f4f85c 100644 --- a/server/src/compiler.rs +++ b/server/src/compiler.rs @@ -1,7 +1,7 @@ use crate::world::MemoryWorld; use std::collections::HashMap; use typst::diag::{SourceDiagnostic, Warned}; -use typst::layout::PagedDocument; +use typst_layout::PagedDocument; use typst_pdf::{pdf, PdfOptions}; use typst_render::render; @@ -16,15 +16,15 @@ impl TypstCompiler { &self, text: String, files: HashMap>, - ) -> Result<(Vec, String), Vec> { + ) -> Result<(Vec, String), Vec<(SourceDiagnostic, Option>)>> { let world = MemoryWorld::new(text, files); match typst::compile::(&world) { Warned { output: Ok(doc), warnings: _, } => { - let svgs = doc.pages.iter().map(typst_svg::svg).collect(); - let thumbnail = if let Some(page) = doc.pages.first() { + let svgs = doc.pages().iter().map(typst_svg::svg).collect(); + let thumbnail = if let Some(page) = doc.pages().first() { typst_svg::svg(page) } else { String::new() @@ -35,7 +35,11 @@ impl TypstCompiler { output: Err(errors), warnings: _, } => { - let diag = errors.into_iter().collect(); + use typst::World; + let diag = errors.into_iter().map(|d| { + let range = d.span.id().and_then(|id| world.source(id).ok()).and_then(|s| s.range(d.span)); + (d, range) + }).collect(); Err(diag) } } @@ -45,7 +49,7 @@ impl TypstCompiler { &self, text: String, files: HashMap>, - ) -> Result, Vec> { + ) -> Result, Vec<(SourceDiagnostic, Option>)>> { let world = MemoryWorld::new(text, files); match typst::compile::(&world) { Warned { @@ -61,7 +65,13 @@ impl TypstCompiler { Warned { output: Err(errors), warnings: _, - } => Err(errors.into_iter().collect()), + } => { + use typst::World; + Err(errors.into_iter().map(|d| { + let range = d.span.id().and_then(|id| world.source(id).ok()).and_then(|s| s.range(d.span)); + (d, range) + }).collect()) + }, } } @@ -69,14 +79,14 @@ impl TypstCompiler { &self, text: String, files: HashMap>, - ) -> Result, Vec> { + ) -> Result, Vec<(SourceDiagnostic, Option>)>> { let world = MemoryWorld::new(text, files); match typst::compile::(&world) { Warned { output: Ok(doc), warnings: _, } => { - if let Some(page) = doc.pages.first() { + if let Some(page) = doc.pages().first() { let pixmap = render(page, 2.0); if let Ok(encoded) = pixmap.encode_png() { return Ok(encoded); @@ -87,7 +97,13 @@ impl TypstCompiler { Warned { output: Err(errors), warnings: _, - } => Err(errors.into_iter().collect()), + } => { + use typst::World; + Err(errors.into_iter().map(|d| { + let range = d.span.id().and_then(|id| world.source(id).ok()).and_then(|s| s.range(d.span)); + (d, range) + }).collect()) + }, } } } diff --git a/server/src/docs.rs b/server/src/docs.rs index f08bd77..0559c28 100644 --- a/server/src/docs.rs +++ b/server/src/docs.rs @@ -233,6 +233,7 @@ pub async fn upload_file( let (_, folder_id) = doc_exists.unwrap(); let mut uploaded_filename = String::new(); + let mut font_family = None; if let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? { let file_name = field.file_name().unwrap_or("unnamed").to_string(); @@ -241,6 +242,12 @@ pub async fn upload_file( let file_id = Uuid::new_v4().to_string(); + if file_name.to_lowercase().ends_with(".ttf") || file_name.to_lowercase().ends_with(".otf") { + if let Some(font) = typst::text::Font::iter(typst::foundations::Bytes::new(data.clone())).next() { + font_family = Some(font.info().family.clone()); + } + } + sqlx::query("INSERT INTO files (id, owner_id, document_id, folder_id, name, mime_type, data) VALUES ($1, $2, $3, $4, $5, $6, $7)") .bind(&file_id) .bind(&user_id) @@ -256,5 +263,8 @@ pub async fn upload_file( uploaded_filename = file_name; } - Ok(Json(serde_json::json!({"filename": uploaded_filename}))) + Ok(Json(serde_json::json!({ + "filename": uploaded_filename, + "font_family": font_family + }))) } diff --git a/server/src/files.rs b/server/src/files.rs index 6157399..a04bef5 100644 --- a/server/src/files.rs +++ b/server/src/files.rs @@ -63,6 +63,7 @@ pub async fn upload_file_global( .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; let mut uploaded_files = vec![]; + let mut font_families = vec![]; while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? { let file_name = field.file_name().unwrap_or("unnamed").to_string(); @@ -71,6 +72,13 @@ pub async fn upload_file_global( let file_id = Uuid::new_v4().to_string(); + let mut font_family = None; + if file_name.to_lowercase().ends_with(".ttf") || file_name.to_lowercase().ends_with(".otf") { + if let Some(font) = typst::text::Font::iter(typst::foundations::Bytes::new(data.clone())).next() { + font_family = Some(font.info().family.clone()); + } + } + sqlx::query("INSERT INTO files (id, owner_id, folder_id, name, mime_type, data) VALUES ($1, $2, $3, $4, $5, $6)") .bind(&file_id) .bind(&user_id) @@ -83,9 +91,13 @@ pub async fn upload_file_global( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; uploaded_files.push(file_name); + font_families.push(font_family); } - Ok(Json(serde_json::json!({"files": uploaded_files}))) + Ok(Json(serde_json::json!({ + "files": uploaded_files, + "font_families": font_families + }))) } pub async fn get_file_data( @@ -135,6 +147,35 @@ pub async fn delete_file( Ok(StatusCode::NO_CONTENT) } +pub async fn list_fonts( + State(state): State, + jar: SignedCookieJar, +) -> Result>, (StatusCode, String)> { + let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + + let files = sqlx::query_as::<_, (String,)>( + "SELECT name FROM files WHERE owner_id = $1" + ) + .bind(&user_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let mut fonts = Vec::new(); + for (name,) in files { + if name.to_lowercase().ends_with(".ttf") || name.to_lowercase().ends_with(".otf") { + if let Some(stem) = std::path::Path::new(&name).file_stem() { + if let Some(stem_str) = stem.to_str() { + fonts.push(stem_str.to_string()); + } + } + } + } + + Ok(Json(fonts)) +} + #[derive(Deserialize)] pub struct UpdateFileRequest { pub name: Option, diff --git a/server/src/handlers.rs b/server/src/handlers.rs index 38094eb..02ad2e8 100644 --- a/server/src/handlers.rs +++ b/server/src/handlers.rs @@ -64,6 +64,8 @@ pub struct CompileResponse { pub struct Diagnostic { pub message: String, pub severity: String, + pub from: Option, + pub to: Option, } pub async fn yjs_handler( @@ -232,9 +234,11 @@ pub async fn compile_handler( Err(diags) => { let errors = diags .into_iter() - .map(|d| Diagnostic { + .map(|(d, range)| Diagnostic { message: d.message.to_string(), severity: format!("{:?}", d.severity), + from: range.as_ref().map(|r| r.start), + to: range.as_ref().map(|r| r.end), }) .collect(); Json(CompileResponse { @@ -464,3 +468,150 @@ pub async fn pandoc_import_handler( ) .into_response() } + +pub async fn lsp_handler( + ws: axum::extract::ws::WebSocketUpgrade, + Path(id): Path, + State(state): State, + jar: axum_extra::extract::cookie::SignedCookieJar, +) -> impl IntoResponse { + let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); + + let doc = match sqlx::query_as::<_, crate::models::Document>("SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = $1").bind(&id).fetch_optional(&state.db).await { + Ok(Some(d)) => d, + _ => return (StatusCode::NOT_FOUND, "Document not found").into_response(), + }; + + let mut has_access = false; + if let Some(uid) = &user_id_opt { + if &doc.owner_id == uid { + has_access = true; + } else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = $1 AND user_id = $2") + .bind(&id) + .bind(uid) + .fetch_optional(&state.db) + .await + { + has_access = true; + } + } + if !has_access { + if let Some(pr) = &doc.public_role { + if pr == "viewer" || pr == "editor" { + has_access = true; + } + } + } + + if !has_access { + return (StatusCode::FORBIDDEN, "Forbidden").into_response(); + } + + let mut files_map = std::collections::HashMap::new(); + if let Ok(files) = sqlx::query_as::<_, (String, Vec)>("SELECT name, data FROM files WHERE owner_id = $1") + .bind(doc.owner_id) + .fetch_all(&state.db) + .await + { + for (name, data) in files { + files_map.insert(name, data); + } + } + + ws.on_upgrade(move |socket| async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader}; + use tokio::process::Command; + use std::process::Stdio; + + let temp_dir = tempfile::tempdir().unwrap(); + + for (name, data) in files_map { + let path = temp_dir.path().join(&name); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&path, data); + } + + let mut child = Command::new("tinymist") + .arg("lsp") + .arg("--font-path") + .arg(temp_dir.path()) + .current_dir(temp_dir.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("Failed to start tinymist lsp"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut stdout_reader = BufReader::new(stdout); + + let (mut ws_tx, mut ws_rx) = socket.split(); + + let root_uri = format!("file://{}", temp_dir.path().display()); + let init_msg = serde_json::json!({ + "type": "init", + "rootUri": root_uri + }); + use futures_util::SinkExt; + let _ = ws_tx.send(axum::extract::ws::Message::Text(init_msg.to_string().into())).await; + + let ws_to_lsp = tokio::spawn(async move { + while let Some(Ok(axum::extract::ws::Message::Text(msg))) = ws_rx.next().await { + let content_length = format!("Content-Length: {}\r\n\r\n", msg.len()); + if stdin.write_all(content_length.as_bytes()).await.is_err() { + break; + } + if stdin.write_all(msg.as_bytes()).await.is_err() { + break; + } + } + }); + + let lsp_to_ws = tokio::spawn(async move { + loop { + let mut content_length = 0; + let mut header = String::new(); + loop { + let mut char_buf = [0; 1]; + if stdout_reader.read_exact(&mut char_buf).await.is_err() { + return; + } + header.push(char_buf[0] as char); + if header.ends_with("\r\n\r\n") { + break; + } + } + + for line in header.split("\r\n") { + if line.starts_with("Content-Length: ") { + if let Ok(len) = line["Content-Length: ".len()..].trim().parse::() { + content_length = len; + } + } + } + + if content_length == 0 { continue; } + + let mut body = vec![0; content_length]; + if stdout_reader.read_exact(&mut body).await.is_err() { + break; + } + + if let Ok(text) = String::from_utf8(body) { + use futures_util::SinkExt; + if ws_tx.send(axum::extract::ws::Message::Text(text.into())).await.is_err() { + break; + } + } + } + }); + + tokio::select! { + _ = ws_to_lsp => {} + _ = lsp_to_ws => {} + _ = child.wait() => {} + } + }) +} diff --git a/server/src/main.rs b/server/src/main.rs index bd1e5cb..00239ec 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -69,6 +69,7 @@ async fn main() { .route("/export/{format}", post(export_handler)) .route("/export/pandoc/{format}", post(handlers::pandoc_export_handler)) .route("/import/pandoc", post(handlers::pandoc_import_handler)) + .route("/lsp/{id}", get(handlers::lsp_handler)) .route("/auth/register", post(auth::register)) .route("/auth/login", post(auth::login)) .route("/auth/logout", post(auth::logout)) @@ -77,6 +78,7 @@ async fn main() { .route("/auth/change-password", put(auth::change_password)) .route("/folders", get(folders::list_folders).post(folders::create_folder)) .route("/folders/{id}", delete(folders::delete_folder).patch(folders::update_folder)) + .route("/fonts", get(files::list_fonts)) .route("/files", get(files::list_files).post(files::upload_file_global)) .route("/files/{id}", delete(files::delete_file).patch(files::update_file)) .route("/files/{id}/data", get(files::get_file_data)) diff --git a/server/src/world.rs b/server/src/world.rs index 92a31e8..e6d6184 100644 --- a/server/src/world.rs +++ b/server/src/world.rs @@ -2,13 +2,13 @@ use chrono::Datelike; use std::collections::HashMap; use typst::diag::{FileError, FileResult}; -use typst::foundations::{Bytes, Datetime}; -use typst::syntax::{FileId, Source, VirtualPath}; -use typst_kit::download::{Downloader, ProgressSink}; -use typst_kit::package::PackageStorage; +use typst::foundations::{Bytes, Datetime, Duration}; +use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot}; use typst::text::{Font, FontBook}; use typst::World; use typst::{Library, LibraryExt}; +use typst_kit::downloader::SystemDownloader; +use typst_kit::packages::SystemPackages; pub struct MemoryWorld { library: typst::utils::LazyHash, @@ -17,15 +17,18 @@ pub struct MemoryWorld { files: HashMap>, book: typst::utils::LazyHash, fonts: Vec, - packages: PackageStorage, + packages: SystemPackages, } impl MemoryWorld { pub fn new(text: String, files: HashMap>) -> Self { - let main = FileId::new(None, VirtualPath::new("main.typ")); + let main = FileId::new(RootedPath::new( + VirtualRoot::Project, + VirtualPath::new("main.typ").unwrap(), + )); let source = Source::new(main, text); - let downloader = Downloader::new("TypstDrive (typst-kit)"); - let packages = PackageStorage::new(None, None, downloader); + let downloader = SystemDownloader::new("TypstDrive (typst-kit)"); + let packages = SystemPackages::new(downloader); let mut book = FontBook::new(); let mut fonts = Vec::new(); @@ -41,7 +44,7 @@ impl MemoryWorld { // Add custom fonts from files for (name, data) in &files { - if name.ends_with(".ttf") || name.ends_with(".otf") { + if name.to_lowercase().ends_with(".ttf") || name.to_lowercase().ends_with(".otf") { for font in Font::iter(Bytes::new(data.clone())) { let info = font.info().clone(); book.push(info.clone()); @@ -87,39 +90,39 @@ impl World for MemoryWorld { fn source(&self, id: FileId) -> FileResult { if id == self.main { Ok(self.source.clone()) - } else if let Some(package) = id.package() { - let dir = self + } else if let VirtualRoot::Package(package) = id.root() { + let root = self .packages - .prepare_package(package, &mut ProgressSink) + .obtain(package) .map_err(|e| FileError::Other(Some(e.to_string().into())))?; - let path = id.vpath().resolve(&dir).ok_or_else(|| FileError::NotFound(id.vpath().as_rootless_path().into()))?; - let data = std::fs::read(&path).map_err(|_| FileError::NotFound(id.vpath().as_rootless_path().into()))?; - let text = String::from_utf8(data).map_err(|_| FileError::InvalidUtf8)?; + let data = root.load(id.vpath())?; + let text = std::str::from_utf8(&data) + .map_err(|_| FileError::InvalidUtf8)? + .to_owned(); Ok(Source::new(id, text)) } else { - Err(FileError::NotFound( - id.vpath().as_rootless_path().into(), - )) + Err(FileError::NotFound(id.vpath().get_without_slash().into())) } } fn file(&self, id: FileId) -> FileResult { if id == self.main { Ok(Bytes::from_string(self.source.text().to_string())) - } else if let Some(package) = id.package() { - let dir = self + } else if let VirtualRoot::Package(package) = id.root() { + let root = self .packages - .prepare_package(package, &mut ProgressSink) + .obtain(package) .map_err(|e| FileError::Other(Some(e.to_string().into())))?; - let path = id.vpath().resolve(&dir).ok_or_else(|| FileError::NotFound(id.vpath().as_rootless_path().into()))?; - let data = std::fs::read(&path).map_err(|_| FileError::NotFound(id.vpath().as_rootless_path().into()))?; - Ok(Bytes::new(data)) - } else if let Some(data) = self.files.get(&id.vpath().as_rootless_path().to_string_lossy().to_string().replace("\\", "/")) { + root.load(id.vpath()) + } else if let Some(data) = self.files.get( + &id.vpath() + .get_without_slash() + .to_string() + .replace("\\", "/"), + ) { Ok(Bytes::new(data.clone())) } else { - Err(FileError::NotFound( - id.vpath().as_rootless_path().into(), - )) + Err(FileError::NotFound(id.vpath().get_without_slash().into())) } } @@ -127,11 +130,12 @@ impl World for MemoryWorld { self.fonts.get(index).cloned() } - fn today(&self, offset: Option) -> Option { + fn today(&self, offset: Option) -> Option { let now = chrono::Local::now(); let date = if let Some(offset) = offset { - let offset = chrono::FixedOffset::east_opt(offset as i32)?; - now.with_timezone(&offset).date_naive() + let offset_secs = offset.hours() as i32 * 3600; + let offset_chrono = chrono::FixedOffset::east_opt(offset_secs)?; + now.with_timezone(&offset_chrono).date_naive() } else { now.date_naive() }; diff --git a/src/lib/components/CommentsSidebar.svelte b/src/lib/components/CommentsSidebar.svelte index a7ad6ac..40dcde2 100644 --- a/src/lib/components/CommentsSidebar.svelte +++ b/src/lib/components/CommentsSidebar.svelte @@ -97,7 +97,7 @@

-
+

Comments

diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index e5d53f4..8a6d39c 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -2,23 +2,138 @@ import { onMount, onDestroy } from 'svelte'; import { EditorState, Compartment } from '@codemirror/state'; import { EditorView, lineNumbers, keymap } from '@codemirror/view'; - import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; + import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; + import { autocompletion, snippetCompletion, type CompletionContext } from '@codemirror/autocomplete'; import { typst, TypstParser, typstHighlight } from 'codemirror-lang-typst'; import { Language } from '@codemirror/language'; import { yCollab } from 'y-codemirror.next'; import { text, provider } from '../ts/yjs-setup'; import { getThemeExtension } from '../ts/themes'; - import { themeStore, darkModeStore, editorViewStore } from '../ts/store'; + import { themeStore, darkModeStore, editorViewStore, editorErrors, triggerLspReconnect } from '../ts/store'; + import { page } from '$app/stores'; + import { LSPClient, languageServerExtensions } from "@codemirror/lsp-client"; + import { setDiagnostics, lintGutter } from '@codemirror/lint'; let editorContainer: HTMLElement; let view: EditorView; let themeCompartment = new Compartment(); + let lspCompartment = new Compartment(); let unsubscribeTheme: () => void; let unsubscribeDark: () => void; + let unsubscribeErrors: () => void; + let unsubscribeLspReconnect: () => void; let currentTheme = 'Catppuccin'; let isDark = true; let state: EditorState; + let client: LSPClient | null = null; + let lsSocket: WebSocket | null = null; + + const typstOptions = [ + snippetCompletion("let ${name} = ${value}", { label: "let", type: "keyword", info: "Variable declaration" }), + snippetCompletion("set ${rule}(${value})", { label: "set", type: "keyword", info: "Set rule" }), + snippetCompletion("show ${selector}: ${rule}", { label: "show", type: "keyword", info: "Show rule" }), + snippetCompletion("import \"${module}\": ${items}", { label: "import", type: "keyword", info: "Import module" }), + snippetCompletion("include \"${file}\"", { label: "include", type: "keyword", info: "Include file" }), + snippetCompletion("if ${condition} {\n\t${}\n}", { label: "if", type: "keyword", info: "If statement" }), + snippetCompletion("else {\n\t${}\n}", { label: "else", type: "keyword", info: "Else statement" }), + snippetCompletion("for ${item} in ${collection} {\n\t${}\n}", { label: "for", type: "keyword", info: "For loop" }), + snippetCompletion("while ${condition} {\n\t${}\n}", { label: "while", type: "keyword", info: "While loop" }), + snippetCompletion("break", { label: "break", type: "keyword", info: "Break loop" }), + snippetCompletion("continue", { label: "continue", type: "keyword", info: "Continue loop" }), + snippetCompletion("return ${value}", { label: "return", type: "keyword", info: "Return value" }), + snippetCompletion("context", { label: "context", type: "keyword", info: "Context expression" }), + snippetCompletion("align(${alignment})[${content}]", { label: "align", type: "function", info: "Align content" }), + snippetCompletion("page(${content})", { label: "page", type: "function", info: "Page configuration" }), + snippetCompletion("pagebreak()", { label: "pagebreak", type: "function", info: "Break page" }), + snippetCompletion("colbreak()", { label: "colbreak", type: "function", info: "Break column" }), + snippetCompletion("place(${alignment})[${content}]", { label: "place", type: "function", info: "Place content" }), + snippetCompletion("columns(${2})[${content}]", { label: "columns", type: "function", info: "Multiple columns" }), + snippetCompletion("pad(${10pt})[${content}]", { label: "pad", type: "function", info: "Pad content" }), + snippetCompletion("stack(dir: ${ttb}, spacing: ${10pt}, ${items})", { label: "stack", type: "function", info: "Stack items" }), + snippetCompletion("grid(columns: ${2}, gutter: ${10pt}, ${items})", { label: "grid", type: "function", info: "Grid layout" }), + snippetCompletion("table(columns: ${2}, ${items})", { label: "table", type: "function", info: "Table layout" }), + snippetCompletion("rect(width: ${100%}, height: ${100%})[${content}]", { label: "rect", type: "function", info: "Draw rectangle" }), + snippetCompletion("square(size: ${10pt})[${content}]", { label: "square", type: "function", info: "Draw square" }), + snippetCompletion("circle(radius: ${10pt})[${content}]", { label: "circle", type: "function", info: "Draw circle" }), + snippetCompletion("ellipse(width: ${20pt}, height: ${10pt})[${content}]", { label: "ellipse", type: "function", info: "Draw ellipse" }), + snippetCompletion("line(length: ${100%})", { label: "line", type: "function", info: "Draw line" }), + snippetCompletion("polygon(${vertices})", { label: "polygon", type: "function", info: "Draw polygon" }), + snippetCompletion("path(${vertices})", { label: "path", type: "function", info: "Draw path" }), + snippetCompletion("image(\"${path}\", width: ${100%})", { label: "image", type: "function", info: "Insert image" }), + snippetCompletion("box[${content}]", { label: "box", type: "function", info: "Box inline content" }), + snippetCompletion("block[${content}]", { label: "block", type: "function", info: "Block content" }), + snippetCompletion("figure(${content}, caption: [${caption}])", { label: "figure", type: "function", info: "Figure with caption" }), + snippetCompletion("text(size: ${11pt}, font: \"${Arial}\")[${content}]", { label: "text", type: "function", info: "Text styling" }), + snippetCompletion("heading(level: ${1})[${title}]", { label: "heading", type: "function", info: "Heading" }), + snippetCompletion("par[${content}]", { label: "par", type: "function", info: "Paragraph" }), + snippetCompletion("list([${item}])", { label: "list", type: "function", info: "Bullet list" }), + snippetCompletion("enum([${item}])", { label: "enum", type: "function", info: "Numbered list" }), + snippetCompletion("terms([${term}], [${description}])", { label: "terms", type: "function", info: "Terms list" }), + snippetCompletion("strong[${content}]", { label: "strong", type: "function", info: "Bold text" }), + snippetCompletion("emph[${content}]", { label: "emph", type: "function", info: "Italic text" }), + snippetCompletion("underline[${content}]", { label: "underline", type: "function", info: "Underline text" }), + snippetCompletion("strike[${content}]", { label: "strike", type: "function", info: "Strikethrough text" }), + snippetCompletion("overline[${content}]", { label: "overline", type: "function", info: "Overline text" }), + snippetCompletion("sub[${content}]", { label: "sub", type: "function", info: "Subscript text" }), + snippetCompletion("super[${content}]", { label: "super", type: "function", info: "Superscript text" }), + snippetCompletion("raw(\"${code}\", block: ${true})", { label: "raw", type: "function", info: "Raw code block" }), + snippetCompletion("link(\"${url}\")[${text}]", { label: "link", type: "function", info: "Hyperlink" }), + snippetCompletion("ref(<${label}>)", { label: "ref", type: "function", info: "Reference" }), + snippetCompletion("cite(<${label}>)", { label: "cite", type: "function", info: "Citation" }), + snippetCompletion("bibliography(\"${file.bib}\")", { label: "bibliography", type: "function", info: "Bibliography" }), + snippetCompletion("outline(title: [${Contents}])", { label: "outline", type: "function", info: "Table of contents" }), + snippetCompletion("rgb(\"${#000000}\")", { label: "rgb", type: "function", info: "RGB Color" }), + snippetCompletion("cmyk(${0%}, ${0%}, ${0%}, ${100%})", { label: "cmyk", type: "function", info: "CMYK Color" }), + snippetCompletion("luma(${0%})", { label: "luma", type: "function", info: "Luma (Grayscale) Color" }), + snippetCompletion("color", { label: "color", type: "variable" }), + snippetCompletion("gradient", { label: "gradient", type: "variable" }), + snippetCompletion("pattern(size: (${10pt}, ${10pt}))[${content}]", { label: "pattern", type: "function", info: "Fill pattern" }), + snippetCompletion("type(${value})", { label: "type", type: "function", info: "Get type of value" }), + snippetCompletion("repr(${value})", { label: "repr", type: "function", info: "String representation" }), + snippetCompletion("str(${value})", { label: "str", type: "function", info: "Convert to string" }), + snippetCompletion("int(${value})", { label: "int", type: "function", info: "Convert to integer" }), + snippetCompletion("float(${value})", { label: "float", type: "function", info: "Convert to float" }), + snippetCompletion("datetime(year: ${2024}, month: ${1}, day: ${1})", { label: "datetime", type: "function", info: "Date and time" }), + snippetCompletion("math", { label: "math", type: "variable", info: "Math module" }), + snippetCompletion("calc", { label: "calc", type: "variable", info: "Calc module" }), + snippetCompletion("sys", { label: "sys", type: "variable", info: "System module" }), + snippetCompletion("frac(${num}, ${denom})", { label: "frac", type: "function", info: "Fraction (Math)" }), + snippetCompletion("binom(${n}, ${k})", { label: "binom", type: "function", info: "Binomial (Math)" }), + snippetCompletion("mat(${1}, ${2}; ${3}, ${4})", { label: "mat", type: "function", info: "Matrix (Math)" }), + snippetCompletion("vec(${1}, ${2})", { label: "vec", type: "function", info: "Vector (Math)" }), + snippetCompletion("cases(${a}, ${b})", { label: "cases", type: "function", info: "Cases (Math)" }), + snippetCompletion("sqrt(${x})", { label: "sqrt", type: "function", info: "Square root (Math)" }), + snippetCompletion("root(${3}, ${x})", { label: "root", type: "function", info: "N-th root (Math)" }), + snippetCompletion("abs(${x})", { label: "abs", type: "function", info: "Absolute value (Math)" }), + snippetCompletion("norm(${x})", { label: "norm", type: "function", info: "Norm (Math)" }), + snippetCompletion("floor(${x})", { label: "floor", type: "function", info: "Floor (Math)" }), + snippetCompletion("ceil(${x})", { label: "ceil", type: "function", info: "Ceiling (Math)" }), + snippetCompletion("round(${x})", { label: "round", type: "function", info: "Round (Math)" }), + snippetCompletion("cancel(${x})", { label: "cancel", type: "function", info: "Cancel/strike (Math)" }), + snippetCompletion("attach(${base}, t: ${top}, b: ${bottom})", { label: "attach", type: "function", info: "Attach scripts (Math)" }), + snippetCompletion("scripts(${expr})", { label: "scripts", type: "function", info: "Scripts (Math)" }), + snippetCompletion("limits(${expr})", { label: "limits", type: "function", info: "Limits (Math)" }), + snippetCompletion("op(\"${name}\")", { label: "op", type: "function", info: "Operator (Math)" }), + snippetCompletion("lr(${expr})", { label: "lr", type: "function", info: "Left/Right scales (Math)" }), + snippetCompletion("mid(${|})", { label: "mid", type: "function", info: "Mid delimiter (Math)" }) + ]; + + function typstCompletions(context: CompletionContext) { + let word = context.matchBefore(/[\w#]*/); + if (!word || (word.from == word.to && !context.explicit)) return null; + + let textBefore = word.text; + if (textBefore.startsWith('#')) { + textBefore = textBefore.substring(1); + } + + return { + from: word.text.startsWith('#') ? word.from + 1 : word.from, + options: typstOptions, + validFor: /^[\w]*$/ + }; + } onMount(() => { if (!text || !provider) return; @@ -39,15 +154,20 @@ doc: text.toString(), extensions: [ lineNumbers(), + lintGutter(), history(), - keymap.of([...defaultKeymap, ...historyKeymap] as any), + keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab] as any), myLang, yCollab(text, provider.awareness), + autocompletion({ override: [typstCompletions] }), themeCompartment.of(getThemeExtension(currentTheme as any, isDark)), + lspCompartment.of([]), EditorView.lineWrapping, EditorView.theme({ '&': { height: '100%', fontSize: '14px' }, '.cm-scroller': { overflow: 'auto' }, + '.cm-tooltip': { maxWidth: '500px' }, + '.cm-tooltip-hover': { maxHeight: '300px', overflow: 'auto' } }), ], }); @@ -59,6 +179,26 @@ editorViewStore.set(view); + unsubscribeErrors = editorErrors.subscribe((errors) => { + if (view) { + const docLen = view.state.doc.length; + const safeDiagnostics = errors.filter(e => e.from != null && e.to != null).map(e => { + let from = e.from as number; + let to = e.to as number; + if (from < 0) from = 0; + if (to > docLen) to = docLen; + if (from > to) from = to; + return { + from, + to, + severity: (e.severity.toLowerCase().includes('warning') ? 'warning' : 'error') as 'warning' | 'error', + message: e.message + }; + }); + view.dispatch(setDiagnostics(view.state, safeDiagnostics)); + } + }); + unsubscribeTheme = themeStore.subscribe((themeName) => { if (view) { view.dispatch({ @@ -76,11 +216,91 @@ isDark = dark; } }); + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + const docId = $page.params.id; + + let lsHandlers: ((value: string) => void)[] = []; + let lspInitialized = false; + + const transport = { + send(message: string) { if (lsSocket?.readyState === WebSocket.OPEN) lsSocket.send(message); }, + subscribe(handler: (value: string) => void) { lsHandlers.push(handler); }, + unsubscribe(handler: (value: string) => void) { lsHandlers = lsHandlers.filter(h => h != handler); } + }; + + function connectLsp() { + if (lsSocket) { + lsSocket.close(); + lsSocket = null; + } + + lspInitialized = false; + lsHandlers = []; + + lsSocket = new WebSocket(`${protocol}//${host}/api/lsp/${docId}`); + + lsSocket.onmessage = e => { + const data = e.data.toString(); + if (!lspInitialized) { + try { + const msg = JSON.parse(data); + if (msg.type === 'init') { + lspInitialized = true; + + // Recreate the client because the backend started a completely new LSP process + // which requires a fresh 'initialize' handshake. + client = new LSPClient({ + rootUri: msg.rootUri, + timeout: 10000, + extensions: languageServerExtensions() + }).connect(transport); + + view.dispatch({ + effects: lspCompartment.reconfigure(client.plugin(`${msg.rootUri}/${docId}.typ`, 'typst')) + }); + return; + } + } catch (err) { + // Fallthrough + } + } + + let processedData = data; + if (lspInitialized) { + try { + const msg = JSON.parse(data); + if (msg.method === 'textDocument/publishDiagnostics' && msg.params && msg.params.diagnostics) { + msg.params.diagnostics = msg.params.diagnostics.filter((d: any) => !d.message.toLowerCase().includes('unknown font family')); + processedData = JSON.stringify(msg); + } + } catch (err) {} + } + + for (let h of lsHandlers) h(processedData); + }; + + lsSocket.onopen = () => { + // Waiting for init message from server + }; + } + + connectLsp(); + + unsubscribeLspReconnect = triggerLspReconnect.subscribe((val) => { + if (val > 0) { + connectLsp(); + } + }); }); onDestroy(() => { + if (lsSocket) lsSocket.close(); if (unsubscribeTheme) unsubscribeTheme(); if (unsubscribeDark) unsubscribeDark(); + if (unsubscribeErrors) unsubscribeErrors(); + if (unsubscribeLspReconnect) unsubscribeLspReconnect(); if (view) { view.destroy(); } diff --git a/src/lib/components/PageSettingsModal.svelte b/src/lib/components/PageSettingsModal.svelte index b6782e4..09f4aa2 100644 --- a/src/lib/components/PageSettingsModal.svelte +++ b/src/lib/components/PageSettingsModal.svelte @@ -62,14 +62,14 @@ } -