Update 1.3.0
This commit is contained in:
+6
-5
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# TypstDrive
|
||||
|
||||
[](https://github.com/your-username/typstdrive)
|
||||
[](https://github.com/your-username/typstdrive)
|
||||
[](https://typst.app/)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](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
|
||||
|
||||
<system-reminder>
|
||||
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.
|
||||
</system-reminder>
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
|
||||
+1
-3
@@ -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
|
||||
|
||||
|
||||
+5
-2
@@ -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",
|
||||
|
||||
+5
-3
@@ -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"
|
||||
|
||||
+26
-10
@@ -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<String, Vec<u8>>,
|
||||
) -> Result<(Vec<String>, String), Vec<SourceDiagnostic>> {
|
||||
) -> Result<(Vec<String>, String), Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
match typst::compile::<PagedDocument>(&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<String, Vec<u8>>,
|
||||
) -> Result<Vec<u8>, Vec<SourceDiagnostic>> {
|
||||
) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
match typst::compile::<PagedDocument>(&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<String, Vec<u8>>,
|
||||
) -> Result<Vec<u8>, Vec<SourceDiagnostic>> {
|
||||
) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
match typst::compile::<PagedDocument>(&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())
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -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
|
||||
})))
|
||||
}
|
||||
|
||||
+42
-1
@@ -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<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<String>>, (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<String>,
|
||||
|
||||
+152
-1
@@ -64,6 +64,8 @@ pub struct CompileResponse {
|
||||
pub struct Diagnostic {
|
||||
pub message: String,
|
||||
pub severity: String,
|
||||
pub from: Option<usize>,
|
||||
pub to: Option<usize>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
State(state): State<AppState>,
|
||||
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<u8>)>("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::<usize>() {
|
||||
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() => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
+35
-31
@@ -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<Library>,
|
||||
@@ -17,15 +17,18 @@ pub struct MemoryWorld {
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
book: typst::utils::LazyHash<FontBook>,
|
||||
fonts: Vec<Font>,
|
||||
packages: PackageStorage,
|
||||
packages: SystemPackages,
|
||||
}
|
||||
|
||||
impl MemoryWorld {
|
||||
pub fn new(text: String, files: HashMap<String, Vec<u8>>) -> 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<Source> {
|
||||
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<Bytes> {
|
||||
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<i64>) -> Option<Datetime> {
|
||||
fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
|
||||
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()
|
||||
};
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
|
||||
<div class="fixed right-0 top-0 bottom-0 w-80 bg-[var(--theme-bg)] backdrop-blur-xl border-l shadow-2xl flex flex-col z-[70] transform transition-transform duration-300 border-[var(--theme-border)]">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b bg-gray-50/50 bg-[var(--theme-bg)] text-[var(--theme-text)] border-[var(--theme-border)]">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b bg-[var(--theme-bg)] text-[var(--theme-text)] border-[var(--theme-border)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="mdi:comment-text-multiple-outline" class="text-lg" />
|
||||
<h2 class="text-sm font-semibold text-[var(--theme-text)]">Comments</h2>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -62,14 +62,14 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200" role="presentation" onclick={() => props.onClose()} onkeydown={(e) => { if (e.key === "Enter") { props.onClose(); } }}>
|
||||
<div class="bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 w-full max-w-2xl overflow-hidden flex flex-col max-h-[85vh]" role="dialog" tabindex="-1" aria-modal="true" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.key === 'Escape' && props.onClose()}>
|
||||
<div class="flex justify-between items-center p-5 border-b border-gray-100 dark:border-zinc-800">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200" role="presentation" onclick={() => props.onClose()} onkeydown={(e) => { if (e.key === "Enter") { props.onClose(); } }}>
|
||||
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-xl shadow-2xl border border-[var(--theme-border)] w-full max-w-2xl overflow-hidden flex flex-col max-h-[85vh]" role="dialog" tabindex="-1" aria-modal="true" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.key === 'Escape' && props.onClose()}>
|
||||
<div class="flex justify-between items-center p-5 border-b border-[var(--theme-border)]">
|
||||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||
<Icon icon="mdi:file-document-edit-outline" class="text-blue-500 text-xl" />
|
||||
Document & Page Settings
|
||||
</h2>
|
||||
<button onclick={() => props.onClose()} class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full p-1 transition-colors">
|
||||
<button onclick={() => props.onClose()} class="opacity-60 hover:opacity-100 rounded-full p-1 transition-opacity">
|
||||
<Icon icon="mdi:close" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -77,90 +77,90 @@
|
||||
<div class="p-6 space-y-8 overflow-y-auto flex-1">
|
||||
|
||||
<section>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider mb-4 border-b border-gray-100 dark:border-zinc-800 pb-2">Document Metadata</h3>
|
||||
<h3 class="text-sm font-bold uppercase tracking-wider mb-4 border-b border-[var(--theme-border)] pb-2">Document Metadata</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label for="docTitle" class="text-sm font-medium text-gray-700 dark:text-gray-300">PDF Title</label>
|
||||
<input id="docTitle" type="text" bind:value={docTitle} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="My Report" />
|
||||
<label for="docTitle" class="text-sm font-medium">PDF Title</label>
|
||||
<input id="docTitle" type="text" bind:value={docTitle} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" placeholder="My Report" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="author" class="text-sm font-medium text-gray-700 dark:text-gray-300">Author</label>
|
||||
<input id="author" type="text" bind:value={author} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="Jane Doe" />
|
||||
<label for="author" class="text-sm font-medium">Author</label>
|
||||
<input id="author" type="text" bind:value={author} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" placeholder="Jane Doe" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider mb-4 border-b border-gray-100 dark:border-zinc-800 pb-2">Page Layout</h3>
|
||||
<h3 class="text-sm font-bold uppercase tracking-wider mb-4 border-b border-[var(--theme-border)] pb-2">Page Layout</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label for="paper" class="text-sm font-medium text-gray-700 dark:text-gray-300">Paper Size</label>
|
||||
<select id="paper" bind:value={paper} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2">
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="a4">A4</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="us-letter">US Letter</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="a5">A5</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="presentation-16-9">16:9 Presentation</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="presentation-4-3">4:3 Presentation</option>
|
||||
<label for="paper" class="text-sm font-medium">Paper Size</label>
|
||||
<select id="paper" bind:value={paper} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2">
|
||||
<option value="a4">A4</option>
|
||||
<option value="us-letter">US Letter</option>
|
||||
<option value="a5">A5</option>
|
||||
<option value="presentation-16-9">16:9 Presentation</option>
|
||||
<option value="presentation-4-3">4:3 Presentation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="margin" class="text-sm font-medium text-gray-700 dark:text-gray-300">Margin</label>
|
||||
<input id="margin" type="text" bind:value={margin} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto or 1in" />
|
||||
<label for="margin" class="text-sm font-medium">Margin</label>
|
||||
<input id="margin" type="text" bind:value={margin} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" placeholder="auto or 1in" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="width" class="text-sm font-medium text-gray-700 dark:text-gray-300">Width</label>
|
||||
<input id="width" type="text" bind:value={width} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto" />
|
||||
<label for="width" class="text-sm font-medium">Width</label>
|
||||
<input id="width" type="text" bind:value={width} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" placeholder="auto" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="height" class="text-sm font-medium text-gray-700 dark:text-gray-300">Height</label>
|
||||
<input id="height" type="text" bind:value={height} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto" />
|
||||
<label for="height" class="text-sm font-medium">Height</label>
|
||||
<input id="height" type="text" bind:value={height} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" placeholder="auto" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="columns" class="text-sm font-medium text-gray-700 dark:text-gray-300">Columns</label>
|
||||
<input id="columns" type="number" min="1" max="10" bind:value={columns} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" />
|
||||
<label for="columns" class="text-sm font-medium">Columns</label>
|
||||
<input id="columns" type="number" min="1" max="10" bind:value={columns} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="fill" class="text-sm font-medium text-gray-700 dark:text-gray-300">Background Fill</label>
|
||||
<input id="fill" type="text" bind:value={fill} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto or rgb(200, 200, 200)" />
|
||||
<label for="fill" class="text-sm font-medium">Background Fill</label>
|
||||
<input id="fill" type="text" bind:value={fill} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" placeholder="auto or rgb(200, 200, 200)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<input id="flipped" type="checkbox" bind:checked={flipped} class="rounded text-blue-600 focus:ring-blue-500 bg-gray-50 dark:bg-zinc-900 border-gray-300 dark:border-zinc-700" />
|
||||
<label for="flipped" class="text-sm font-medium text-gray-700 dark:text-gray-300">Landscape Orientation (Flipped)</label>
|
||||
<input id="flipped" type="checkbox" bind:checked={flipped} class="rounded text-blue-600 focus:ring-blue-500 bg-[var(--theme-bg)] border-[var(--theme-border)]" />
|
||||
<label for="flipped" class="text-sm font-medium">Landscape Orientation (Flipped)</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider mb-4 border-b border-gray-100 dark:border-zinc-800 pb-2">Headers & Footers</h3>
|
||||
<h3 class="text-sm font-bold uppercase tracking-wider mb-4 border-b border-[var(--theme-border)] pb-2">Headers & Footers</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label for="numbering" class="text-sm font-medium text-gray-700 dark:text-gray-300">Page Numbering</label>
|
||||
<select id="numbering" bind:value={numbering} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2">
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="none">None</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="1">1, 2, 3</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="1/1">1/3, 2/3, 3/3</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="a">a, b, c</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="i">i, ii, iii</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="I">I, II, III</option>
|
||||
<label for="numbering" class="text-sm font-medium">Page Numbering</label>
|
||||
<select id="numbering" bind:value={numbering} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2">
|
||||
<option value="none">None</option>
|
||||
<option value="1">1, 2, 3</option>
|
||||
<option value="1/1">1/3, 2/3, 3/3</option>
|
||||
<option value="a">a, b, c</option>
|
||||
<option value="i">i, ii, iii</option>
|
||||
<option value="I">I, II, III</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="header" class="text-sm font-medium text-gray-700 dark:text-gray-300">Header Content</label>
|
||||
<input id="header" type="text" bind:value={header} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto or [Text]" />
|
||||
<label for="header" class="text-sm font-medium">Header Content</label>
|
||||
<input id="header" type="text" bind:value={header} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" placeholder="auto or [Text]" />
|
||||
</div>
|
||||
<div class="space-y-2 sm:col-span-2">
|
||||
<label for="footer" class="text-sm font-medium text-gray-700 dark:text-gray-300">Footer Content</label>
|
||||
<input id="footer" type="text" bind:value={footer} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto or [Text]" />
|
||||
<label for="footer" class="text-sm font-medium">Footer Content</label>
|
||||
<input id="footer" type="text" bind:value={footer} class="w-full bg-[var(--theme-bg)] border border-[var(--theme-border)] text-[var(--theme-text)] text-sm rounded-lg px-3 py-2" placeholder="auto or [Text]" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="p-5 border-t border-gray-100 dark:border-zinc-800 flex justify-end gap-3 bg-gray-50/50 dark:bg-zinc-900/50">
|
||||
<button onclick={() => props.onClose()} class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors">
|
||||
<div class="p-5 border-t border-[var(--theme-border)] flex justify-end gap-3" style="background-color: var(--theme-border);">
|
||||
<button onclick={() => props.onClose()} class="px-4 py-2 text-sm font-medium bg-[var(--theme-bg)] opacity-80 hover:opacity-100 rounded-lg transition-opacity border border-[var(--theme-border)]">
|
||||
Cancel
|
||||
</button>
|
||||
<button onclick={apply} class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm">
|
||||
|
||||
@@ -23,14 +23,14 @@
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2 {className}">
|
||||
<Icon icon={themes[$themeStore]?.icon || 'mdi:palette'} class="text-xl text-gray-500 dark:text-gray-400" />
|
||||
<Icon icon={themes[$themeStore]?.icon || 'mdi:palette'} class="text-xl text-[var(--theme-text)] opacity-70" />
|
||||
<select
|
||||
value={selectedValue}
|
||||
onchange={handleChange}
|
||||
class="bg-white/50 dark:bg-black/20 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-white/20 text-sm font-medium rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 block py-1.5 pl-3 pr-8 appearance-none cursor-pointer hover:border-gray-400 dark:hover:border-white/30 transition-colors outline-none"
|
||||
class="bg-[var(--theme-bg)] text-[var(--theme-text)] border border-[var(--theme-border)] text-sm font-medium rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 block py-1.5 pl-3 pr-8 appearance-none cursor-pointer transition-colors outline-none"
|
||||
>
|
||||
{#each themeOptions as opt}
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value={opt.name}>{opt.name}</option>
|
||||
<option class="bg-[var(--theme-bg)] text-[var(--theme-text)]" value={opt.name}>{opt.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { exportTypst } from '../ts/typst-api';
|
||||
import { text, undoManager } from '../ts/yjs-setup';
|
||||
import { connectionStatus, connectedUsers, themeStore, darkModeStore, editorViewStore, documentZoomStore, commentsSidebarOpen, versionHistoryOpen } from '../ts/store';
|
||||
import { text } from '../ts/yjs-setup';
|
||||
import { connectionStatus, connectedUsers, themeStore, darkModeStore, editorViewStore, documentZoomStore, commentsSidebarOpen, versionHistoryOpen, triggerLspReconnect } from '../ts/store';
|
||||
import { themes } from '../ts/themes';
|
||||
import { goto } from '$app/navigation';
|
||||
import ShareModal from './ShareModal.svelte';
|
||||
import PageSettingsModal from './PageSettingsModal.svelte';
|
||||
import ThemePicker from './ThemePicker.svelte';
|
||||
import PresentationMode from "./PresentationMode.svelte";
|
||||
import CommentsSidebar from "./CommentsSidebar.svelte";
|
||||
import VersionHistorySidebar from "./VersionHistorySidebar.svelte";
|
||||
import PresentationMode from "./PresentationMode.svelte";
|
||||
import CommentsSidebar from "./CommentsSidebar.svelte";
|
||||
import VersionHistorySidebar from "./VersionHistorySidebar.svelte";
|
||||
import Icon from '@iconify/svelte';
|
||||
import { undo, redo } from '@codemirror/commands';
|
||||
|
||||
let isShareModalOpen = $state(false);
|
||||
let isPageSettingsOpen = $state(false);
|
||||
@@ -19,6 +20,17 @@ import VersionHistorySidebar from "./VersionHistorySidebar.svelte";
|
||||
|
||||
let { title = 'Untitled Document', docId = undefined, isViewer = false } = $props<{ title?: string, docId?: string, isViewer?: boolean }>();
|
||||
|
||||
let uploadedFonts = $state<string[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
fetch('/api/fonts')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
uploadedFonts = Array.isArray(data) ? data : [];
|
||||
})
|
||||
.catch(console.error);
|
||||
});
|
||||
|
||||
function handleExport(format: 'pdf' | 'png' | 'svg' | 'typ') {
|
||||
if (!text) return;
|
||||
const content = text.toString();
|
||||
@@ -59,6 +71,44 @@ import VersionHistorySidebar from "./VersionHistorySidebar.svelte";
|
||||
});
|
||||
}
|
||||
|
||||
function handlePrint() {
|
||||
if (!text) return;
|
||||
const content = text.toString();
|
||||
const safeTitle = title.replace(/[^a-z0-9_-]/gi, '_');
|
||||
|
||||
fetch(`/api/export/pdf`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: content, document_id: docId }),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Print failed');
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.position = 'fixed';
|
||||
iframe.style.right = '0';
|
||||
iframe.style.bottom = '0';
|
||||
iframe.style.width = '0';
|
||||
iframe.style.height = '0';
|
||||
iframe.style.border = '0';
|
||||
iframe.src = url;
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
iframe.onload = () => {
|
||||
setTimeout(() => {
|
||||
iframe.contentWindow?.print();
|
||||
}, 100);
|
||||
};
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(`Print failed:`, e);
|
||||
alert(`Failed to print document`);
|
||||
});
|
||||
}
|
||||
|
||||
function handlePandocExport(format: string) {
|
||||
if (!text || !docId) return;
|
||||
const content = text.toString();
|
||||
@@ -107,7 +157,7 @@ import VersionHistorySidebar from "./VersionHistorySidebar.svelte";
|
||||
const oldArgs = match[1];
|
||||
|
||||
|
||||
const propRegex = new RegExp(`${propKeyTrimmed}\\s*:\\s*(?:"[^"]*"|[^,]+)`);
|
||||
const propRegex = new RegExp(`${propKeyTrimmed}\\s*:\\s*(?:\\([^)]*\\)|"[^"]*"|[^,)]+)`);
|
||||
let newArgs;
|
||||
if (propRegex.test(oldArgs)) {
|
||||
newArgs = oldArgs.replace(propRegex, `${propKeyTrimmed}: ${propValTrimmed}`);
|
||||
@@ -153,11 +203,27 @@ import VersionHistorySidebar from "./VersionHistorySidebar.svelte";
|
||||
body: formData
|
||||
}).then(res => res.json()).then(data => {
|
||||
if (data.filename) {
|
||||
if (data.filename.toLowerCase().endsWith('.ttf') || data.filename.toLowerCase().endsWith('.otf')) {
|
||||
triggerLspReconnect.update(n => n + 1);
|
||||
let stem = data.filename.substring(0, data.filename.lastIndexOf('.'));
|
||||
if (!uploadedFonts.includes(stem)) {
|
||||
uploadedFonts = [...uploadedFonts, stem];
|
||||
}
|
||||
}
|
||||
const view = $editorViewStore;
|
||||
if (view) {
|
||||
const selection = view.state.selection.main;
|
||||
const isFont = data.filename.toLowerCase().endsWith('.ttf') || data.filename.toLowerCase().endsWith('.otf');
|
||||
const replacement = isFont ? `#set text(font: ("New Computer Modern", "${data.filename.replace(/\.[^/.]+$/, "")}"))\n` : `#image("${data.filename}")\n`;
|
||||
let replacement = "";
|
||||
if (isFont) {
|
||||
if (data.font_family) {
|
||||
replacement = `#set text(font: "${data.font_family}")\n`;
|
||||
} else {
|
||||
replacement = `// The font ${data.filename} is available!\n// Type #set text(font: "") and use autocomplete to select its name.\n`;
|
||||
}
|
||||
} else {
|
||||
replacement = `#image("${data.filename}")\n`;
|
||||
}
|
||||
view.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert: replacement },
|
||||
selection: { anchor: selection.from + replacement.length }
|
||||
@@ -315,6 +381,16 @@ import VersionHistorySidebar from "./VersionHistorySidebar.svelte";
|
||||
activeMenu = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUndo() {
|
||||
activeMenu = null;
|
||||
if ($editorViewStore) undo($editorViewStore);
|
||||
}
|
||||
|
||||
function handleRedo() {
|
||||
activeMenu = null;
|
||||
if ($editorViewStore) redo($editorViewStore);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
@@ -352,38 +428,39 @@ import VersionHistorySidebar from "./VersionHistorySidebar.svelte";
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'file' ? null : 'file'; }}
|
||||
class="px-2 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-white/10 transition-colors {activeMenu === 'file' ? 'bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white' : ''}"
|
||||
class="px-2 py-0.5 rounded transition-colors {activeMenu === 'file' ? 'bg-[var(--theme-border)] text-[var(--theme-text)]' : 'hover:bg-[var(--theme-border)]'}"
|
||||
>
|
||||
File
|
||||
</button>
|
||||
{#if activeMenu === 'file'}
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; goto('/dashboard'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">New / Open</button>
|
||||
<button onclick={() => { activeMenu = null; openInfo(); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Document Info</button>
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; goto('/dashboard'); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">New / Open</button>
|
||||
<button onclick={() => { activeMenu = null; openInfo(); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">Document Info</button>
|
||||
{#if !isViewer}
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; handleSaveVersion(); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Save Version</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; openRename(); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Rename</button>
|
||||
<button onclick={() => { activeMenu = null; isShareModalOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Share</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; isPageSettingsOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Page Settings</button>
|
||||
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; handleSaveVersion(); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">Save Version</button>
|
||||
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; openRename(); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">Rename</button>
|
||||
<button onclick={() => { activeMenu = null; isShareModalOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">Share</button>
|
||||
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; isPageSettingsOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">Page Settings</button>
|
||||
{/if}
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<div class="px-4 py-1.5 text-xs font-semibold text-gray-400 uppercase tracking-wider">Download</div>
|
||||
<button onclick={() => { activeMenu = null; handleExport('typ'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">.typ source</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('pdf'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">.pdf document</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('png'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">.png image</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('svg'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">.svg graphics</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<div class="px-4 py-1.5 text-xs font-semibold text-gray-400 uppercase tracking-wider">Export (Pandoc)</div>
|
||||
<button onclick={() => { activeMenu = null; handlePandocExport('docx'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Word (.docx)</button>
|
||||
<button onclick={() => { activeMenu = null; handlePandocExport('latex'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">LaTeX (.tex)</button>
|
||||
<button onclick={() => { activeMenu = null; handlePandocExport('markdown'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Markdown (.md)</button>
|
||||
<button onclick={() => { activeMenu = null; handlePandocExport('html'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">HTML (.html)</button>
|
||||
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
||||
<div class="px-4 py-1.5 text-xs font-semibold uppercase tracking-wider opacity-60">Download</div>
|
||||
<button onclick={() => { activeMenu = null; handlePrint(); }} class="w-full text-left px-4 py-1 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:printer" /> Print Document</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('typ'); }} class="w-full text-left px-4 py-1 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:code-braces" /> .typ source</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('pdf'); }} class="w-full text-left px-4 py-1 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:file-pdf-box" /> .pdf document</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('png'); }} class="w-full text-left px-4 py-1 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:image" /> .png image</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('svg'); }} class="w-full text-left px-4 py-1 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:svg" /> .svg graphics</button>
|
||||
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
||||
<div class="px-4 py-1.5 text-xs font-semibold uppercase tracking-wider opacity-60">Export (Pandoc)</div>
|
||||
<button onclick={() => { activeMenu = null; handlePandocExport('docx'); }} class="w-full text-left px-4 py-1 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">Word (.docx)</button>
|
||||
<button onclick={() => { activeMenu = null; handlePandocExport('latex'); }} class="w-full text-left px-4 py-1 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">LaTeX (.tex)</button>
|
||||
<button onclick={() => { activeMenu = null; handlePandocExport('markdown'); }} class="w-full text-left px-4 py-1 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">Markdown (.md)</button>
|
||||
<button onclick={() => { activeMenu = null; handlePandocExport('html'); }} class="w-full text-left px-4 py-1 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">HTML (.html)</button>
|
||||
{#if !isViewer}
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; deleteDoc(); }} class="w-full text-left px-4 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/10">Delete</button>
|
||||
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; deleteDoc(); }} class="w-full text-left px-4 py-1.5 text-sm text-red-500 hover:bg-red-500/10">Delete</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -393,18 +470,18 @@ import VersionHistorySidebar from "./VersionHistorySidebar.svelte";
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'edit' ? null : 'edit'; }}
|
||||
class="px-2 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-white/10 transition-colors {activeMenu === 'edit' ? 'bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white' : ''}"
|
||||
class="px-2 py-0.5 rounded transition-colors {activeMenu === 'edit' ? 'bg-[var(--theme-border)] text-[var(--theme-text)]' : 'hover:bg-[var(--theme-border)]'}"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{#if activeMenu === 'edit'}
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; if (undoManager) undoManager.undo(); else document.execCommand('undo'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Undo (Ctrl+Z)</button>
|
||||
<button onclick={() => { activeMenu = null; if (undoManager) undoManager.redo(); else document.execCommand('redo'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Redo (Ctrl+Y)</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; document.execCommand('cut'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Cut (Ctrl+X)</button>
|
||||
<button onclick={() => { activeMenu = null; document.execCommand('copy'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Copy (Ctrl+C)</button>
|
||||
<button onclick={() => { activeMenu = null; navigator.clipboard.readText().then(t => document.execCommand('insertText', false, t)); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Paste (Ctrl+V)</button>
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100]">
|
||||
<button onclick={handleUndo} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">Undo (Ctrl+Z)</button>
|
||||
<button onclick={handleRedo} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">Redo (Ctrl+Y)</button>
|
||||
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; document.execCommand('cut'); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">Cut (Ctrl+X)</button>
|
||||
<button onclick={() => { activeMenu = null; document.execCommand('copy'); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">Copy (Ctrl+C)</button>
|
||||
<button onclick={() => { activeMenu = null; navigator.clipboard.readText().then(t => document.execCommand('insertText', false, t)); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">Paste (Ctrl+V)</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -413,17 +490,17 @@ import VersionHistorySidebar from "./VersionHistorySidebar.svelte";
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'view' ? null : 'view'; }}
|
||||
class="px-2 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-white/10 transition-colors {activeMenu === 'view' ? 'bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white' : ''}"
|
||||
class="px-2 py-0.5 rounded transition-colors {activeMenu === 'view' ? 'bg-[var(--theme-border)] text-[var(--theme-text)]' : 'hover:bg-[var(--theme-border)]'}"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
{#if activeMenu === 'view'}
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; $versionHistoryOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center justify-between">
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; $versionHistoryOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center justify-between">
|
||||
Version History
|
||||
</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; $darkModeStore = !$darkModeStore; document.documentElement.classList.toggle('dark', $darkModeStore); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center justify-between">
|
||||
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; $darkModeStore = !$darkModeStore; document.documentElement.classList.toggle('dark', $darkModeStore); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center justify-between">
|
||||
Dark Mode
|
||||
<Icon icon={$darkModeStore ? "mdi:check" : ""} class="text-sm" />
|
||||
</button>
|
||||
@@ -492,6 +569,10 @@ import VersionHistorySidebar from "./VersionHistorySidebar.svelte";
|
||||
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<div class="flex items-center bg-gray-100/50 dark:bg-zinc-900/50 rounded-md p-0.5 border border-gray-200 dark:border-white/10">
|
||||
<button onclick={handlePrint} class="flex items-center gap-1 px-3 py-1 text-xs font-bold text-[var(--theme-bg)] bg-[var(--theme-text)] hover:opacity-80 rounded transition-all shadow-sm" title="Print Document">
|
||||
<Icon icon="mdi:printer" class="text-sm" />
|
||||
Print
|
||||
</button>
|
||||
<button onclick={() => handleExport('typ')} class="px-2.5 py-1 text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-all" title="Download .typ source">TYP</button>
|
||||
<button onclick={() => handleExport('svg')} class="px-2.5 py-1 text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-all" title="Export as SVG">SVG</button>
|
||||
<button onclick={() => handleExport('png')} class="px-2.5 py-1 text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-all" title="Export as PNG">PNG</button>
|
||||
@@ -543,16 +624,23 @@ import VersionHistorySidebar from "./VersionHistorySidebar.svelte";
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="font-select" class="text-[11px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Font</label>
|
||||
<label for="font-select" class="text-[11px] font-semibold uppercase tracking-wider opacity-60">Font</label>
|
||||
<select
|
||||
id="font-select"
|
||||
onchange={(e) => insertTypstConfig('text', `font: "${e.currentTarget.value}"`)}
|
||||
class="bg-white dark:bg-black/20 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-white/20 text-xs rounded shadow-sm focus:ring-blue-500 focus:border-blue-500 block py-1 pl-2 pr-6 appearance-none cursor-pointer hover:border-gray-400 dark:hover:border-zinc-600 transition-colors"
|
||||
class="bg-[var(--theme-bg)] text-[var(--theme-text)] border border-[var(--theme-border)] text-xs rounded shadow-sm focus:ring-blue-500 focus:border-blue-500 block py-1 pl-2 pr-6 appearance-none cursor-pointer transition-colors"
|
||||
>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="New Computer Modern">Default (New CM)</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="Libertinus Serif">Libertinus Serif</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="PT Sans">PT Sans</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="Roboto">Roboto</option>
|
||||
<option class="bg-[var(--theme-bg)] text-[var(--theme-text)]" value="New Computer Modern">Default (New CM)</option>
|
||||
<option class="bg-[var(--theme-bg)] text-[var(--theme-text)]" value="Libertinus Serif">Libertinus Serif</option>
|
||||
<option class="bg-[var(--theme-bg)] text-[var(--theme-text)]" value="PT Sans">PT Sans</option>
|
||||
<option class="bg-[var(--theme-bg)] text-[var(--theme-text)]" value="Roboto">Roboto</option>
|
||||
{#if uploadedFonts.length > 0}
|
||||
<optgroup label="Uploaded Fonts" class="bg-[var(--theme-bg)] text-[var(--theme-text)] font-semibold italic">
|
||||
{#each uploadedFonts as font}
|
||||
<option class="bg-[var(--theme-bg)] text-[var(--theme-text)] not-italic font-normal" value={font}>{font}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -562,7 +650,7 @@ import VersionHistorySidebar from "./VersionHistorySidebar.svelte";
|
||||
{#if !isViewer}
|
||||
<button
|
||||
onclick={() => (isPageSettingsOpen = true)}
|
||||
class="flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold text-gray-600 hover:text-gray-900 bg-white hover:bg-gray-100 border border-gray-300 rounded shadow-sm dark:text-gray-300 dark:bg-black/20 dark:border-white/20 dark:hover:bg-white/10 dark:hover:text-white transition-colors"
|
||||
class="flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold bg-[var(--theme-bg)] text-[var(--theme-text)] border border-[var(--theme-border)] rounded shadow-sm transition-colors opacity-90 hover:opacity-100"
|
||||
>
|
||||
<Icon icon="mdi:file-document-edit-outline" class="text-sm" />
|
||||
Page Settings
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
|
||||
<div class="h-40 w-full bg-gray-50 dark:bg-black/40 rounded-t-xl overflow-hidden flex items-center justify-center border-b border-gray-100 dark:border-white/10 relative pointer-events-none">
|
||||
{#if doc.thumbnail_svg}
|
||||
<div class="w-full h-full flex items-center justify-center p-2 bg-white transition-transform duration-300 group-hover:scale-110">
|
||||
<img src={`data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(doc.thumbnail_svg)))}`} class="max-w-full max-h-full object-contain shadow-sm border border-gray-200" alt="Thumbnail" draggable="false" />
|
||||
<div class="w-full h-full flex items-start justify-center bg-white transition-transform duration-300 group-hover:scale-110">
|
||||
<img src={`data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(doc.thumbnail_svg)))}`} class="w-full h-auto shadow-sm border border-gray-200" alt="Thumbnail" draggable="false" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full transition-transform duration-300 group-hover:scale-110">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-white/5 cursor-pointer group transition-colors {dragOverFolderId === folder.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''}"
|
||||
class="flex flex-row items-center p-3 bg-white/50 dark:bg-black/20 backdrop-blur-sm border border-gray-200 dark:border-white/10 rounded-xl shadow-sm hover:shadow-md cursor-pointer group transition-all duration-200 relative {dragOverFolderId === folder.id ? 'ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'hover:-translate-y-0.5 hover:border-gray-300 dark:hover:border-white/20'}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => navigateToFolder(folder)}
|
||||
@@ -21,16 +21,12 @@
|
||||
ondragleave={() => setDragOverFolderId(null)}
|
||||
ondrop={(e) => handleDrop(e, folder.id)}
|
||||
>
|
||||
<div class="flex items-center gap-3 pointer-events-none">
|
||||
<div class="flex items-center justify-center w-10 h-10 bg-yellow-50 dark:bg-yellow-500/10 rounded-lg group-hover:scale-105 transition-transform pointer-events-none shrink-0 mr-3">
|
||||
<Icon icon="mdi:folder" class="text-2xl text-yellow-500" />
|
||||
<span class="font-medium text-gray-900 dark:text-white">{folder.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400 hidden sm:block pointer-events-none">
|
||||
{new Date(folder.created_at ? (folder.created_at.endsWith('Z') ? folder.created_at : folder.created_at + 'Z') : Date.now()).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
<button aria-label="Delete folder" onclick={(e) => { e.stopPropagation(); deleteFolder(folder.id, folder.name); }} class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity p-2 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20">
|
||||
<Icon icon="mdi:trash-can-outline" class="text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 dark:text-white text-sm truncate w-full pointer-events-none pr-6">{folder.name}</span>
|
||||
|
||||
<button aria-label="Delete folder" onclick={(e) => { e.stopPropagation(); deleteFolder(folder.id, folder.name); }} class="absolute top-1/2 -translate-y-1/2 right-2 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-900/20 shrink-0">
|
||||
<Icon icon="mdi:trash-can-outline" class="text-base" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Diagnostic } from './typst-api';
|
||||
|
||||
export const themeStore = writable('Catppuccin');
|
||||
export const darkModeStore = writable(true);
|
||||
@@ -8,6 +9,8 @@ export const documentZoomStore = writable(100);
|
||||
export const commentsSidebarOpen = writable(false);
|
||||
export const versionHistoryOpen = writable(false);
|
||||
export const commentReference = writable('');
|
||||
export const editorErrors = writable<Diagnostic[]>([]);
|
||||
export const triggerLspReconnect = writable(0);
|
||||
|
||||
export interface AwarenessUser {
|
||||
clientId: number;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface Diagnostic {
|
||||
message: string;
|
||||
severity: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
}
|
||||
|
||||
export interface CompileResponse {
|
||||
|
||||
@@ -384,7 +384,7 @@
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white tracking-tight">My Documents</h2>
|
||||
|
||||
<div class="relative plus-dropdown-container">
|
||||
<button onclick={() => showPlusDropdown = !showPlusDropdown} class="flex items-center justify-center bg-blue-600 hover:bg-blue-700 text-white w-10 h-10 rounded-full shadow-md hover:shadow-lg transition-all duration-200 focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-zinc-950 focus:ring-blue-500 transform hover:-translate-y-0.5">
|
||||
<button onclick={() => showPlusDropdown = !showPlusDropdown} class="flex items-center justify-center text-[var(--theme-text)] bg-[var(--theme-border)] opacity-90 hover:opacity-100 w-10 h-10 rounded-full shadow-md hover:shadow-lg transition-all duration-200 transform hover:-translate-y-0.5 border border-white/10 dark:border-black/20">
|
||||
<Icon icon="mdi:plus" class="text-2xl" />
|
||||
</button>
|
||||
|
||||
@@ -466,11 +466,11 @@
|
||||
{:else}
|
||||
|
||||
{#if folders.length > 0}
|
||||
<div class="mb-8 bg-white/50 dark:bg-black/20 backdrop-blur-sm rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-white/40 dark:bg-white/5 text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
<div class="mb-8">
|
||||
<div class="px-2 py-3 text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Folders
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-white/5">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{#each folders as folder}
|
||||
<FolderRow
|
||||
{folder}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { compileTypst } from '$lib/ts/typst-api';
|
||||
import type { Diagnostic } from '$lib/ts/typst-api';
|
||||
import { page } from '$app/stores';
|
||||
import { commentsSidebarOpen, commentReference, editorViewStore } from '$lib/ts/store';
|
||||
import { commentsSidebarOpen, commentReference, editorViewStore, editorErrors } from '$lib/ts/store';
|
||||
|
||||
let svgs = $state<string[]>([]);
|
||||
let errors = $state<Diagnostic[]>([]);
|
||||
@@ -60,8 +60,10 @@
|
||||
if (res.svgs) {
|
||||
svgs = res.svgs;
|
||||
errors = [];
|
||||
$editorErrors = [];
|
||||
} else if (res.errors) {
|
||||
errors = res.errors;
|
||||
$editorErrors = res.errors;
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -136,15 +138,15 @@
|
||||
<!-- Custom Context Menu for Editor -->
|
||||
{#if contextMenu.show}
|
||||
<div
|
||||
class="fixed z-[9999] bg-white dark:bg-zinc-800 rounded-lg shadow-xl border border-gray-200 dark:border-white/10 py-1 min-w-[200px] overflow-hidden"
|
||||
class="fixed z-[9999] bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-lg shadow-xl border border-[var(--theme-border)] py-1 min-w-[200px] overflow-hidden"
|
||||
style="left: {contextMenu.x}px; top: {contextMenu.y}px;"
|
||||
>
|
||||
<button onclick={handleAddComment} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/10 flex items-center gap-2">
|
||||
<button onclick={handleAddComment} class="w-full text-left px-4 py-2 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-blue-500"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
|
||||
Add Comment on Selection
|
||||
</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { navigator.clipboard.writeText(contextMenu.text); closeContextMenu(); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/10 flex items-center gap-2">
|
||||
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
||||
<button onclick={() => { navigator.clipboard.writeText(contextMenu.text); closeContextMenu(); }} class="w-full text-left px-4 py-2 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-gray-500"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
||||
Copy Text
|
||||
</button>
|
||||
|
||||
+1
-1
Submodule typst updated: b33de9de11...d6848a802e
+1
-2
@@ -2,10 +2,9 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import wasm from 'vite-plugin-wasm';
|
||||
import topLevelAwait from 'vite-plugin-top-level-await';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit(), wasm(), topLevelAwait()],
|
||||
plugins: [tailwindcss(), sveltekit(), wasm()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:3000',
|
||||
|
||||
Reference in New Issue
Block a user