From d36a4b2451de53269bb1b23b82f5c0a1c2324fbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 29 Oct 2026 13:07:22 +0100 Subject: [PATCH] v0.2.96 --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 26 ++++- build.rs | 67 ++++++++++- dev.sh | 3 +- lang/en.json | 24 +++- lang/pl.json | 24 +++- src/api/mod.rs | 135 ++++++++++++++++++--- src/api/pads_public.rs | 59 +++++++++- src/app/mod.rs | 19 +-- src/config/mod.rs | 4 +- src/db/mod.rs | 49 ++++++++ static/css/styles.css | 91 +++++++++++++++ static/editor.html | 25 +++- static/js/editor-format.js | 6 +- static/js/markdown.js | 14 ++- static/js/note-api.js | 6 + static/js/note-editor.js | 138 +++++++++++++++++++++- static/js/pdf-export.js | 233 +++++++++++++++++++++++++++++-------- static/js/public.js | 3 + static/js/vendor-libs.js | 9 +- static/js/workspace.js | 101 +++++++++++++++- 22 files changed, 926 insertions(+), 114 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 60be62c..a4a6091 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2674,7 +2674,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.95" +version = "0.2.96" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 8126f5e..b70d8ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.95" +version = "0.2.96" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/README.md b/README.md index 16ae99d..10a3db0 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,19 @@ RustPad is a collaborative Markdown editor with standalone notes and workspaces. +## Release 0.2.96 — PDF layout, inline rename and PDF tags + +- Pressing Enter while renaming a note title no longer bubbles to the header copy-link shortcut. +- The More menu exposes PDF page break, header, footer, align-top, and align-center directives. +- `[PDF_PAGEBREAK]` is the canonical explicit PDF page-break tag. Legacy `[PAGEBREAK]` remains supported for existing notes. +- PDF controls are listed near the top of **More** and the menu scrolls on short viewports. +- Static assets use a build-content cache key while the application version remains `0.2.96`, so rebuilt patch packages do not reuse stale JS/CSS under the same semantic version. +- Added inline note renaming from the note editor and workspace list/table; URLs and slugs remain unchanged. +- Double-clicking the title no longer triggers the surrounding copy-link action. Successful renames show a confirmation toast; failures retain the existing error notification. +- PDF export adds header/footer directives and vertical top/center alignment, with more stable page-break handling for headings and footers. +- Mermaid export no longer leaves stale load-error messages next to successfully rendered diagrams, and failed library loads can be retried. +- Translation files are revalidated instead of being cached for 24 hours, so newly added UI strings become available after deployment. + ## Release 0.2.95 — dark PDF export - PDF export now includes a dark-mode checkbox enabled by default. @@ -74,13 +87,22 @@ The selected interface language is stored in the browser and can be changed from - Mentioned users are listed below the participant/author badges with a count of matching mentions; repeated clicks jump to the next occurrence and cycle through them in editor, split, or preview views. - Indented fenced code blocks shift their whole preview block rather than just the code text. - Optional line numbers in fenced code blocks. -- PDF export with selectable A5/A4/A3, Letter, 16:9, and 4:3 page formats; optional color/background preservation keeps syntax highlighting, alerts, quotes, highlights, and table styling; `[PAGEBREAK]` creates an explicit hard PDF page break while `---` remains a normal Markdown horizontal rule. +- PDF export with selectable A5/A4/A3, Letter, 16:9, and 4:3 page formats; optional color/background preservation keeps syntax highlighting, alerts, quotes, highlights, and table styling; `[PDF_PAGEBREAK]` creates an explicit hard PDF page break while `---` remains a normal Markdown horizontal rule. ## PDF export and page breaks Use the **PDF** button in the editor header to choose the page format and margins, then generate a browser-native PDF print view. The export supports A5/A4/A3, Letter, 16:9, and 4:3 layouts. Syntax highlighting, alerts, quotes, highlighted text, table headers, inline code, links, and other styled Markdown elements keep their colors and backgrounds automatically. The exporter re-applies Highlight.js to the print copy and uses print-safe light/dark palettes so code colors do not depend on the editor theme. The end of the exported document includes one subtle localized RustPad footer. -A Markdown line containing only `[PAGEBREAK]` creates an explicit page break during PDF export. Insert it from **More → Page break (PDF)** or with `Ctrl/Cmd+Enter`. A normal `---` remains a Markdown horizontal rule and no longer forces a new PDF page. +A Markdown line containing only `[PDF_PAGEBREAK]` creates an explicit page break during PDF export. Existing `[PAGEBREAK]` markers remain supported for backwards compatibility. Insert it from **More → Page break (PDF)** or with `Ctrl/Cmd+Enter`. A normal `---` remains a Markdown horizontal rule and no longer forces a new PDF page. + +PDF layout directives can reserve document-level slots and change vertical alignment: + +- `[PDF_HEADER]` moves the next Markdown block into a header pinned to the top of the first PDF page. +- `[PDF_FOOTER]` moves the next Markdown block into the footer aligned to the bottom of the last PDF page. +- `[PDF_ALIGN_TOP]` keeps normal top-aligned content. +- `[PDF_ALIGN_CENTER]` vertically centers content when the document fits on one page; multi-page documents stay top-aligned. + +Trailing empty source lines are ignored for footer placement. Mermaid export reuses an already rendered SVG when available and removes stale preview error messages before printing. ## Fenced code blocks and language aliases diff --git a/build.rs b/build.rs index 206881d..eb33209 100644 --- a/build.rs +++ b/build.rs @@ -1,7 +1,64 @@ -use std::{env, fs, path::PathBuf}; +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +const FNV_OFFSET: u64 = 0xcbf29ce484222325; +const FNV_PRIME: u64 = 0x100000001b3; + +fn hash_bytes(mut hash: u64, bytes: &[u8]) -> u64 { + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + hash +} + +fn collect_files(root: &Path, dir: &Path, files: &mut Vec) { + let mut entries = fs::read_dir(dir) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", dir.display())) + .filter_map(Result::ok) + .map(|entry| entry.path()) + .collect::>(); + entries.sort(); + for path in entries { + if path.is_dir() { + collect_files(root, &path, files); + } else if path.is_file() { + println!( + "cargo:rerun-if-changed={}", + path.strip_prefix(root).unwrap_or(&path).display() + ); + files.push(path); + } + } +} + +fn asset_version(manifest_dir: &Path) -> String { + let mut files = Vec::new(); + collect_files(manifest_dir, &manifest_dir.join("static"), &mut files); + collect_files(manifest_dir, &manifest_dir.join("lang"), &mut files); + files.sort(); + + let mut hash = FNV_OFFSET; + for path in files { + let relative = path.strip_prefix(manifest_dir).unwrap_or(&path); + hash = hash_bytes(hash, relative.to_string_lossy().as_bytes()); + hash = hash_bytes(hash, &[0]); + let bytes = fs::read(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + hash = hash_bytes(hash, &bytes); + hash = hash_bytes(hash, &[0xff]); + } + format!( + "{}-{hash:016x}", + env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION") + ) +} fn main() { println!("cargo:rerun-if-changed=lang"); + println!("cargo:rerun-if-changed=static"); let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); let lang_dir = manifest_dir.join("lang"); @@ -31,5 +88,11 @@ fn main() { generated.push_str("];\n"); let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); - fs::write(out_dir.join("languages.rs"), generated).expect("failed to generate embedded languages"); + fs::write(out_dir.join("languages.rs"), generated) + .expect("failed to generate embedded languages"); + + println!( + "cargo:rustc-env=RUSTPAD_ASSET_VERSION={}", + asset_version(&manifest_dir) + ); } diff --git a/dev.sh b/dev.sh index 584c1d3..c60e1f7 100644 --- a/dev.sh +++ b/dev.sh @@ -13,8 +13,7 @@ export UPLOAD_MAX_SIZE_MB="${UPLOAD_MAX_SIZE_MB:-20}" export STATIC_DIR="${STATIC_DIR:-$(pwd)/static}" export RUST_LOG="${RUST_LOG:-rustpad=debug,tower_http=info}" -# Generate a new asset version on each run to prevent stale HTML and JavaScript. -export ASSET_VERSION="${ASSET_VERSION:-dev-$(date +%s)}" +# Asset URLs are cache-busted by a build-time content hash. update_browser_libs() { local python_bin="" diff --git a/lang/en.json b/lang/en.json index 8dd89ba..8f7ca58 100644 --- a/lang/en.json +++ b/lang/en.json @@ -387,13 +387,17 @@ "editor.pageBreak": "Page break (PDF)", "editor.pageBreakShortcut": "Page break (PDF) · Ctrl/Cmd+Enter", "editor.pageFormat": "Page format", - "editor.pdfPageBreakHelp": "Use Page break (PDF) or Ctrl/Cmd+Enter to start a new PDF page.", + "editor.pdfPageBreakHelp": "Use PDF page break or Ctrl/Cmd+Enter to start a new PDF page. The legacy [PAGEBREAK] tag is still supported.", "editor.darkPdf": "Dark PDF", "editor.pdfPrintHelp": "The browser print dialog will open. Choose Save as PDF.", + "editor.pdfTagsHelp": "PDF tags: [PDF_PAGEBREAK] starts a new page. [PDF_HEADER] and [PDF_FOOTER] move the next block to the document header/footer. [PDF_ALIGN_TOP] and [PDF_ALIGN_CENTER] override vertical alignment.", "editor.pdfGeneratedFooter": "Generated by RustPad", "editor.presentation16x9": "16:9 — Presentation", "editor.presentation4x3": "4:3 — Presentation", "editor.wideMargin": "Wide (20 mm)", + "editor.verticalContent": "Vertical content", + "editor.verticalTop": "Top", + "editor.verticalCenterSingle": "Centered (single page)", "editor.failedMermaid": "Failed to load Mermaid.", "editor.fileCount": "{count} files", "editor.font": "Font", @@ -1017,6 +1021,22 @@ "files.link": "Link", "files.markdown": "Markdown", "home.account.loading": "Checking your account...", - "home.account.retry": "Reload page" + "home.account.retry": "Reload page", + "editor.renameHint": "Double-click to rename", + "editor.renameLabel": "Note name", + "editor.renameEmpty": "The note name cannot be empty.", + "editor.renameFailed": "Could not rename note", + "editor.renamed": "Renamed to “{title}”.", + "editor.renamedTitle": "Note renamed", + "workspace.renameHint": "Double-click to rename", + "workspace.renameLabel": "Note name", + "workspace.renameEmpty": "The note name cannot be empty.", + "workspace.renameFailed": "Could not rename note", + "workspace.renamed": "Renamed to “{title}”.", + "workspace.renamedTitle": "Note renamed", + "editor.pdfHeaderTag": "PDF header", + "editor.pdfFooterTag": "PDF footer", + "editor.pdfAlignTopTag": "PDF align top", + "editor.pdfAlignCenterTag": "PDF align center" } } diff --git a/lang/pl.json b/lang/pl.json index 29e511f..07c0f8c 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -387,13 +387,17 @@ "editor.pageBreak": "Podział strony (PDF)", "editor.pageBreakShortcut": "Podział strony (PDF) · Ctrl/Cmd+Enter", "editor.pageFormat": "Format strony", - "editor.pdfPageBreakHelp": "Użyj opcji Podział strony (PDF) lub Ctrl/Cmd+Enter, aby rozpocząć nową stronę PDF.", + "editor.pdfPageBreakHelp": "Użyj podziału strony PDF lub Ctrl/Cmd+Enter, aby rozpocząć nową stronę PDF. Stary tag [PAGEBREAK] nadal jest obsługiwany.", "editor.darkPdf": "Ciemny PDF", "editor.pdfPrintHelp": "Otworzy się okno drukowania przeglądarki. Wybierz Zapisz jako PDF.", + "editor.pdfTagsHelp": "Tagi PDF: [PDF_PAGEBREAK] rozpoczyna nową stronę. [PDF_HEADER] i [PDF_FOOTER] przenoszą następny blok do nagłówka lub stopki dokumentu. [PDF_ALIGN_TOP] i [PDF_ALIGN_CENTER] nadpisują wyrównanie pionowe.", "editor.pdfGeneratedFooter": "Wygenerowano przez RustPad", "editor.presentation16x9": "16:9 — prezentacja", "editor.presentation4x3": "4:3 — prezentacja", "editor.wideMargin": "Szerokie (20 mm)", + "editor.verticalContent": "Położenie zawartości", + "editor.verticalTop": "Do góry", + "editor.verticalCenterSingle": "Wyśrodkowana (jedna strona)", "editor.failedMermaid": "Nie udało się załadować Mermaid.", "editor.fileCount": "{count} plików", "editor.font": "Czcionka", @@ -1017,6 +1021,22 @@ "files.link": "Link", "files.markdown": "Markdown", "home.account.loading": "Sprawdzanie konta...", - "home.account.retry": "Odśwież stronę" + "home.account.retry": "Odśwież stronę", + "editor.renameHint": "Kliknij dwukrotnie, aby zmienić nazwę", + "editor.renameLabel": "Nazwa notatki", + "editor.renameEmpty": "Nazwa notatki nie może być pusta.", + "editor.renameFailed": "Nie udało się zmienić nazwy notatki", + "editor.renamed": "Zmieniono nazwę na „{title}”.", + "editor.renamedTitle": "Nazwa notatki zmieniona", + "workspace.renameHint": "Kliknij dwukrotnie, aby zmienić nazwę", + "workspace.renameLabel": "Nazwa notatki", + "workspace.renameEmpty": "Nazwa notatki nie może być pusta.", + "workspace.renameFailed": "Nie udało się zmienić nazwy notatki", + "workspace.renamed": "Zmieniono nazwę na „{title}”.", + "workspace.renamedTitle": "Nazwa notatki zmieniona", + "editor.pdfHeaderTag": "Nagłówek PDF", + "editor.pdfFooterTag": "Stopka PDF", + "editor.pdfAlignTopTag": "PDF: wyrównaj do góry", + "editor.pdfAlignCenterTag": "PDF: wyśrodkuj pionowo" } } diff --git a/src/api/mod.rs b/src/api/mod.rs index f9f4ce5..c3906e9 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -193,6 +193,25 @@ async fn can_manage_workspace_settings( account_owner || workspace_creator_is_requester(headers, workspace) } +async fn has_explicit_write_permission( + state: &SharedState, + headers: &HeaderMap, + kind: &str, + slug: &str, + supplied_access_token: Option<&str>, +) -> Result { + Ok(request_access_level( + state, + headers, + kind, + slug, + supplied_access_token, + bearer_token(headers), + ) + .await? + >= AccessLevel::Write) +} + async fn has_write_permission( state: &SharedState, headers: &HeaderMap, @@ -363,6 +382,21 @@ pub struct CreateNoteRequest { created_by: Option, } +#[derive(Debug, Deserialize)] +pub struct RenameRequest { + name: String, + #[serde(default)] + password: Option, + #[serde(default)] + access_token: Option, +} + +#[derive(Debug, Serialize)] +pub struct RenameResponse { + title: String, + updated_at: String, +} + #[derive(Debug, Deserialize)] pub struct SetWorkspacePasswordRequest { password: String, @@ -439,6 +473,7 @@ fn normalize_list_per_page(value: usize) -> usize { pub struct NoteListItem { slug: String, title: String, + can_rename: bool, created_at: String, updated_at: String, url: String, @@ -469,6 +504,7 @@ pub struct NoteInfo { workspace_title: String, slug: String, title: String, + can_rename: bool, protected: bool, access_level: String, note_protected: bool, @@ -695,14 +731,8 @@ pub async fn create_workspace( } else { None }; - let workspace = db::create_workspace( - &state.db, - &slug, - title, - password, - created_by_guest_id, - ) - .await?; + let workspace = + db::create_workspace(&state.db, &slug, title, password, created_by_guest_id).await?; if let Some(user) = account_user { sqlx::query(queries::get( state.db.kind(), @@ -772,12 +802,8 @@ pub async fn set_workspace_settings( "Only the workspace owner can change workspace settings.", )); } - db::set_workspace_archive_deleted_notes( - &state.db, - workspace.id, - payload.archive_deleted_notes, - ) - .await?; + db::set_workspace_archive_deleted_notes(&state.db, workspace.id, payload.archive_deleted_notes) + .await?; let workspace = db::find_workspace(&state.db, &workspace_slug) .await? .ok_or_else(ApiError::not_found_workspace)?; @@ -870,8 +896,7 @@ pub async fn set_workspace_password( "Only the workspace owner can set its password.", )); } - let except_client_id = - crate::websocket::clean_collaboration_client_id(payload.client_id); + let except_client_id = crate::websocket::clean_collaboration_client_id(payload.client_id); let password = validate_password(Some(payload.password.as_str()))? .ok_or_else(|| ApiError::bad_request("Password is required."))?; db::set_workspace_password(&state.db, &workspace_slug, password).await?; @@ -902,6 +927,21 @@ pub async fn open_workspace( &headers, ) .await?; + let password_write_access = + db::verify_workspace_password(&workspace, payload.password.as_deref()); + let explicit_write_access = has_explicit_write_permission( + &state, + &headers, + "workspace", + &workspace.slug, + resource_request_token( + &headers, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + ), + ) + .await?; let mut access_level = effective_header_access_level( &state, &headers, @@ -911,7 +951,7 @@ pub async fn open_workspace( workspace.password_hash.is_some(), ) .await?; - if db::verify_workspace_password(&workspace, payload.password.as_deref()) { + if password_write_access { access_level = AccessLevel::Write; } let workspace_account_owner = crate::auth::is_resource_owner( @@ -981,6 +1021,10 @@ pub async fn open_workspace( url: format!("/w/{}/n/{}", workspace.slug, note.slug), slug: note.slug, title: note.title, + can_rename: workspace_owner + || note_owner + || password_write_access + || explicit_write_access, created_at: db::normalize_timestamp(¬e.created_at), updated_at: db::normalize_timestamp(¬e.updated_at), protected: note.protected, @@ -1118,6 +1162,7 @@ pub async fn create_note( url: format!("/w/{workspace_slug}/n/{slug}"), slug: note.slug, title: note.title, + can_rename: true, created_at: db::normalize_timestamp(¬e.created_at), updated_at: db::normalize_timestamp(¬e.updated_at), protected: note.protected, @@ -1132,6 +1177,59 @@ pub async fn create_note( )) } +pub async fn rename_note( + State(state): State, + headers: HeaderMap, + Path((workspace_slug, note_slug)): Path<(String, String)>, + Json(payload): Json, +) -> Result, ApiError> { + let (workspace, note) = authorized_note( + &state, + &workspace_slug, + ¬e_slug, + payload.password.as_deref(), + resource_request_token( + &headers, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + ), + bearer_token(&headers), + &headers, + ) + .await?; + let requester_is_owner = requester_owns_note(&state, &headers, &workspace, ¬e).await?; + let password_write_access = + db::verify_workspace_password(&workspace, payload.password.as_deref()); + let explicit_write_access = has_explicit_write_permission( + &state, + &headers, + "workspace", + &workspace_slug, + resource_request_token( + &headers, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + ), + ) + .await?; + if !requester_is_owner && !password_write_access && !explicit_write_access { + return Err(ApiError::forbidden( + "Only the note owner or a user with write permission can rename it.", + )); + } + let title = validate_name(&payload.name, "Note name")?; + db::rename_note(&state.db, note.id, workspace.id, title).await?; + let updated = db::find_note(&state.db, workspace.id, ¬e_slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + Ok(Json(RenameResponse { + title: updated.title, + updated_at: db::normalize_timestamp(&updated.updated_at), + })) +} + fn clean_editor_color(value: Option<&str>) -> Result, ApiError> { let Some(value) = value else { return Ok(None); @@ -1263,6 +1361,8 @@ pub async fn note_info( let password_write_access = has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?; let requester_is_owner = workspace_owner || workspace_guest_owner || note_owner; + let explicit_rename_access = + has_explicit_write_permission(&state, &headers, "workspace", &workspace_slug, None).await?; let can_manage_authorship = can_manage_resource_settings( workspace_owner || workspace_guest_owner, note_owner, @@ -1288,6 +1388,7 @@ pub async fn note_info( workspace_title: workspace.title, slug: note.slug, title: note.title, + can_rename: requester_is_owner || explicit_rename_access, protected: workspace.password_hash.is_some(), access_level: access_level_name(access_level).into(), note_protected: note.protected, diff --git a/src/api/pads_public.rs b/src/api/pads_public.rs index 5c5c610..28c971c 100644 --- a/src/api/pads_public.rs +++ b/src/api/pads_public.rs @@ -35,6 +35,7 @@ pub struct CreatePadResponse { pub struct PadInfo { slug: String, title: String, + can_rename: bool, protected: bool, access_level: String, allow_public_task_updates: bool, @@ -139,6 +140,8 @@ pub async fn pad_info( .unwrap_or(false); let guest_owner = pad_creator_is_requester(&headers, &pad); let password_write_access = has_password_write_access(&state, &headers, "pad", &slug).await?; + let explicit_rename_access = + has_explicit_write_permission(&state, &headers, "pad", &slug, None).await?; let can_manage_authorship = can_manage_resource_settings(account_owner, guest_owner, password_write_access); let upload_max_size_bytes = resource_upload_limit(&state, &headers, "pad", &slug).await?; @@ -155,6 +158,7 @@ pub async fn pad_info( Ok(Json(PadInfo { slug: pad.slug, title: pad.title, + can_rename: guest_owner || explicit_rename_access, protected: pad.password_hash.is_some(), access_level: access_level_name(access_level).into(), allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?, @@ -190,6 +194,47 @@ pub async fn pad_info( })) } +pub async fn rename_pad( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Json(payload): Json, +) -> Result, ApiError> { + let pad = authorized_pad( + &state, + &slug, + payload.password.as_deref(), + resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()), + bearer_token(&headers), + &headers, + ) + .await?; + let guest_owner = pad_creator_is_requester(&headers, &pad); + let password_write_access = db::verify_pad_password(&pad, payload.password.as_deref()); + let explicit_write_access = has_explicit_write_permission( + &state, + &headers, + "pad", + &slug, + resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()), + ) + .await?; + if !guest_owner && !password_write_access && !explicit_write_access { + return Err(ApiError::forbidden( + "Only the note owner or a user with write permission can rename it.", + )); + } + let title = validate_name(&payload.name, "Note name")?; + db::rename_pad(&state.db, pad.id, title).await?; + let updated = db::find_pad(&state.db, &slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + Ok(Json(RenameResponse { + title: updated.title, + updated_at: db::normalize_timestamp(&updated.updated_at), + })) +} + pub async fn set_pad_password( State(state): State, headers: HeaderMap, @@ -202,15 +247,17 @@ pub async fn set_pad_password( if pad.password_hash.is_some() { return Err(ApiError::bad_request("This note already has a password.")); } - let account_owner = crate::auth::is_resource_owner( - &state, "pad", &slug, user_session_token(&headers), - ).await.unwrap_or(false); + let account_owner = + crate::auth::is_resource_owner(&state, "pad", &slug, user_session_token(&headers)) + .await + .unwrap_or(false); let guest_owner = pad_creator_is_requester(&headers, &pad); if !can_set_resource_password(pad.password_hash.is_some(), account_owner, guest_owner) { - return Err(ApiError::forbidden("Only the note owner can set its password.")); + return Err(ApiError::forbidden( + "Only the note owner can set its password.", + )); } - let except_client_id = - crate::websocket::clean_collaboration_client_id(payload.client_id); + let except_client_id = crate::websocket::clean_collaboration_client_id(payload.client_id); let password = validate_password(Some(payload.password.as_str()))? .ok_or_else(|| ApiError::bad_request("Password is required."))?; db::set_pad_password(&state.db, &slug, password).await?; diff --git a/src/app/mod.rs b/src/app/mod.rs index 63245ba..bf2f9e9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -161,6 +161,7 @@ pub fn router( .route("/api/public/{token}/tasks", post(api::update_public_task)) .route("/api/pads", post(api::create_pad)) .route("/api/pads/{slug}", get(api::pad_info)) + .route("/api/pads/{slug}/rename", post(api::rename_pad)) .route("/api/pads/{slug}/history", post(api::pad_history)) .route( "/api/pads/{slug}/editor-color", @@ -211,6 +212,10 @@ pub fn router( "/api/workspaces/{workspace_slug}/notes/{note_slug}", get(api::note_info).delete(api::delete_note), ) + .route( + "/api/workspaces/{workspace_slug}/notes/{note_slug}/rename", + post(api::rename_note), + ) .route( "/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-color", get(api::note_editor_color).post(api::set_note_editor_color), @@ -287,10 +292,9 @@ pub fn files_router(state: SharedState) -> Router { async fn language_catalog() -> Response { let mut response = Json(crate::i18n::metadata()).into_response(); - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static("public, max-age=300"), - ); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); response } @@ -307,10 +311,9 @@ async fn language_bundle(Path(code): Path) -> Response { header::CONTENT_TYPE, HeaderValue::from_static("application/json; charset=utf-8"), ); - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static("public, max-age=86400"), - ); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); response } diff --git a/src/config/mod.rs b/src/config/mod.rs index c141637..7a27f29 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -181,7 +181,9 @@ impl Config { "GUEST_UPLOAD_MAX_SIZE_MB", guest_upload_max_size_mb, )?, - asset_version: format!("{}", env!("CARGO_PKG_VERSION")), + asset_version: option_env!("RUSTPAD_ASSET_VERSION") + .unwrap_or(env!("CARGO_PKG_VERSION")) + .to_string(), http_compression: values.bool("HTTP_COMPRESSION", false)?, asset_cache_max_age_seconds: values .nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?, diff --git a/src/db/mod.rs b/src/db/mod.rs index 0fb69bf..a487ca7 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -442,6 +442,36 @@ pub async fn create_note( .ok_or(sqlx::Error::RowNotFound) } +pub async fn rename_note( + pool: &Database, + note_id: i64, + workspace_id: i64, + title: &str, +) -> Result<(), sqlx::Error> { + let (note_query, workspace_query) = match pool.kind() { + DatabaseKind::Postgres => ( + "UPDATE notes SET title = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2", + "UPDATE workspaces SET updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $1", + ), + DatabaseKind::Sqlite | DatabaseKind::MySql => ( + "UPDATE notes SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + "UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?", + ), + }; + let mut tx = pool.pool().begin().await?; + sqlx::query(note_query) + .bind(title) + .bind(note_id) + .execute(&mut *tx) + .await?; + sqlx::query(workspace_query) + .bind(workspace_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + pub async fn save_revision( pool: &Database, note_id: i64, @@ -650,6 +680,25 @@ pub async fn create_pad( .await } +pub async fn rename_pad( + pool: &Database, + pad_id: i64, + title: &str, +) -> Result<(), sqlx::Error> { + let query = match pool.kind() { + DatabaseKind::Postgres => + "UPDATE pads SET title = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2", + DatabaseKind::Sqlite | DatabaseKind::MySql => + "UPDATE pads SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + }; + sqlx::query(query) + .bind(title) + .bind(pad_id) + .execute(pool.pool()) + .await?; + Ok(()) +} + pub async fn set_pad_password( pool: &Database, slug: &str, diff --git a/static/css/styles.css b/static/css/styles.css index 1c4851e..6882018 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -655,6 +655,53 @@ textarea:focus { text-overflow: ellipsis; } +.document-title--renamable { + cursor: text; + text-decoration-color: transparent; + text-decoration-line: underline; + text-decoration-style: dotted; + text-underline-offset: 3px; + transition: text-decoration-color .15s ease; +} + +.document-title--renamable:hover { + text-decoration-color: color-mix(in srgb, var(--muted) 55%, transparent); +} + +/* When the title can be renamed, don't present the whole heading as a copy + button. The URL and the explicit Copy link button still copy the link. */ +.document-heading--copy.document-heading--has-renamable-title:hover { + background: transparent; + box-shadow: none; +} + +.document-heading--has-renamable-title .document-url:hover { + color: var(--text-secondary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.document-title-inline-input { + box-sizing: border-box; + width: min(42vw, 520px); + max-width: 100%; + margin: -2px 0; + padding: 1px 2px 2px; + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--accent) 70%, var(--border)); + border-radius: 0; + outline: none; + background: transparent; + color: var(--text); + font: inherit; + line-height: 1.35; +} + +.document-title-inline-input:focus { + border-bottom-color: var(--accent); + box-shadow: none; +} + .document-url { overflow: hidden; max-width: 360px; @@ -1129,6 +1176,10 @@ textarea::selection { border-top-style: dashed; } +.markdown-body .pdf-layout-marker { + display: none !important; +} + .editor-footer { display: flex; align-items: center; @@ -3829,6 +3880,36 @@ dialog::backdrop { text-decoration: underline; } +.note-list-inline-title--renamable { + cursor: text; +} + +.note-list-inline-title--editing { + display: inline-block; + width: min(100%, 42ch); + text-decoration: none; +} + +.note-title-inline-input { + box-sizing: border-box; + width: 100%; + min-width: 12ch; + max-width: 42ch; + padding: 4px 7px; + border: 1px solid var(--accent); + border-radius: 6px; + outline: none; + background: var(--control-bg); + color: var(--text); + font: inherit; + font-weight: inherit; + line-height: 1.3; +} + +.note-title-inline-input:focus { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); +} + .note-status { color: var(--muted); font-size: .75rem; @@ -4338,6 +4419,9 @@ dialog::backdrop { grid-template-columns: repeat(2, minmax(120px, 1fr)); gap: 4px; width: 290px; + max-height: min(70vh, 560px); + overflow-y: auto; + overscroll-behavior: contain; padding: 8px; border: 1px solid var(--border-strong); border-radius: 9px; @@ -4350,6 +4434,13 @@ dialog::backdrop { text-align: left; } +.markdown-more-separator { + grid-column: 1 / -1; + height: 1px; + margin: 4px 2px; + background: var(--border); +} + .hljs { color: var(--dialog-text); } diff --git a/static/editor.html b/static/editor.html index 0ee8f36..72844f8 100644 --- a/static/editor.html +++ b/static/editor.html @@ -127,9 +127,18 @@ data-format="definition">Definition + data-format="superscript">Superscript + + + diff --git a/static/js/editor-format.js b/static/js/editor-format.js index fcf8980..219ccf6 100644 --- a/static/js/editor-format.js +++ b/static/js/editor-format.js @@ -55,7 +55,7 @@ function insertStandaloneLine(editor, content) { } export function insertPageBreak(editor) { - insertStandaloneLine(editor, "[PAGEBREAK]"); + insertStandaloneLine(editor, "[PDF_PAGEBREAK]"); } export function insertHorizontalRule(editor) { @@ -107,6 +107,10 @@ export function applyFormat(editor, format) { if (format === "footnote") toggleWrap(editor, "", `[^1]\n\n[^1]: ${t("editor.format.footnote", {}, "Footnote text")}`, t("editor.format.textWithFootnote", {}, "Text with footnote")); if (format === "definition") toggleWrap(editor, "", `\n: ${t("editor.format.definition", {}, "Definition")}`, t("editor.format.term", {}, "Term")); if (format === "page-break") insertPageBreak(editor); + if (format === "pdf-header") insertStandaloneLine(editor, "[PDF_HEADER]"); + if (format === "pdf-footer") insertStandaloneLine(editor, "[PDF_FOOTER]"); + if (format === "pdf-align-top") insertStandaloneLine(editor, "[PDF_ALIGN_TOP]"); + if (format === "pdf-align-center") insertStandaloneLine(editor, "[PDF_ALIGN_CENTER]"); if (format === "horizontal-rule") insertHorizontalRule(editor); editor.focus(); editor.dispatchEvent(new Event("input", { bubbles: true })); diff --git a/static/js/markdown.js b/static/js/markdown.js index 9f882fc..4ddac65 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -48,7 +48,6 @@ function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&" function highlightMentions(html) { if (!markdownMentionMatcher) return html; - // Process text nodes only, not markup, links or attributes. return html.split(/(<[^>]*>)/g).map(part => part.startsWith("<") ? part : part.replace(markdownMentionMatcher, (match, before, name) => { const user = markdownMentionLookup.get(name.toLocaleLowerCase()); return user ? `${before}@${escapeHtml(user.nickname)}` : match; @@ -632,10 +631,15 @@ export function renderMarkdown(source, lineOffset = 0, documentHeadings = null) html += `
${inline(lines[index].replace(/^:\s+/, ""))}
`; } html += ``; - } else if (/^\[PAGEBREAK\]$/i.test(line.trim())) html += ``; - else if (/^---+$/.test(line.trim())) html += ``; - else if (line.trim()) html += `${inline(line)}

`; - else html += `
`; + } else if (/^\[(?:PDF_PAGEBREAK|PAGEBREAK)\]$/i.test(line.trim())) html += ``; + else { + const pdfDirective = line.trim().match(/^\[PDF_(HEADER|FOOTER|ALIGN_TOP|ALIGN_CENTER)\]$/i); + if (pdfDirective) { + html += ``; + } else if (/^---+$/.test(line.trim())) html += ``; + else if (line.trim()) html += `${inline(line)}

`; + else html += `
`; + } } } diff --git a/static/js/note-api.js b/static/js/note-api.js index 1cdb7fb..7f20a11 100644 --- a/static/js/note-api.js +++ b/static/js/note-api.js @@ -27,6 +27,9 @@ export function createPadAdapter() { addressSelector: "#document-url", title: info => `${info.title} · RustPad`, loadInfo: headers => api(base, { headers }), + renameTitle: (headers, name) => api(`${base}/rename`, { + method: "POST", headers, body: JSON.stringify({ name }), + }), loadColor: headers => api(`${base}/editor-color`, { headers }), saveColor: (headers, color) => api(`${base}/editor-color`, { method: "POST", headers, body: JSON.stringify({ color }) }), saveEditorSettings: (headers, settings) => api(`${base}/editor-settings`, { method: "POST", headers, body: JSON.stringify(settings) }), @@ -74,6 +77,9 @@ export function createWorkspaceNoteAdapter() { addressSelector: "#document-url", title: info => `${info.title} · ${info.workspace_title}`, loadInfo: headers => api(base, { headers }), + renameTitle: (headers, name) => api(`${base}/rename`, { + method: "POST", headers, body: JSON.stringify({ name }), + }), loadColor: headers => api(`${base}/editor-color`, { headers }), saveColor: (headers, color) => api(`${base}/editor-color`, { method: "POST", headers, body: JSON.stringify({ color }) }), saveEditorSettings: (headers, settings) => api(`${base}/editor-settings`, { method: "POST", headers, body: JSON.stringify(settings) }), diff --git a/static/js/note-editor.js b/static/js/note-editor.js index feef68a..8951fd9 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -37,7 +37,7 @@ import { syncSelectControl } from "@rustpad/select"; import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs"; export function startNoteEditor(adapter) { - const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer"); + const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer"), documentTitle = document.querySelector("#document-title"); const modeToggle = document.querySelector("#mode-toggle"), toolbarCollapseToggle = document.querySelector("#toolbar-collapse-toggle"), navbarCollapseToggle = document.querySelector("#navbar-collapse-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog"); const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), mobileConnectionDetails = document.querySelector("#mobile-connection-details"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message"); const saveState = document.querySelector("#save-state"), favoriteToggle = document.querySelector("#favorite-toggle"); @@ -309,13 +309,93 @@ export function startNoteEditor(adapter) { render(); } + function syncInlineTitleRename() { + if (!documentTitle) return; + const enabled = Boolean(adapter.renameTitle && info?.can_rename); + documentTitle.classList.toggle("document-title--renamable", enabled); + documentTitle.closest("#document-link-copy")?.classList.toggle("document-heading--has-renamable-title", enabled); + if (enabled) documentTitle.title = t("editor.renameHint", {}, "Double-click to rename"); + else documentTitle.removeAttribute("title"); + } + + function beginInlineTitleRename() { + if (!documentTitle || !adapter.renameTitle || !info?.can_rename || documentTitle.querySelector("input")) return; + const previous = String(info.title || documentTitle.textContent || "").trim(); + const input = document.createElement("input"); + input.className = "document-title-inline-input"; + input.type = "text"; + input.maxLength = 80; + input.value = previous; + input.setAttribute("aria-label", t("editor.renameLabel", {}, "Note name")); + documentTitle.replaceChildren(input); + documentTitle.classList.add("document-title--editing"); + let finished = false; + + const restore = title => { + documentTitle.classList.remove("document-title--editing"); + documentTitle.textContent = title; + syncInlineTitleRename(); + }; + const finish = async save => { + if (finished) return; + finished = true; + const next = input.value.trim(); + if (!save) { restore(previous); return; } + if (!next) { + restore(previous); + return; + } + if (next === previous) { restore(previous); return; } + input.disabled = true; + try { + const result = await adapter.renameTitle(sessionHeaders(), next); + info = { ...info, title: result.title || next, updated_at: result.updated_at || info.updated_at }; + restore(info.title); + document.title = adapter.title(info); + toast.success( + t("editor.renamed", { title: info.title }, `Renamed to “${info.title}”.`), + { title: t("editor.renamedTitle", {}, "Note renamed") }, + ); + } catch (error) { + restore(previous); + toast.danger(error.message, { title: t("editor.renameFailed", {}, "Could not rename note") }); + } + }; + + input.addEventListener("keydown", event => { + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + void finish(true); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + void finish(false); + } + }); + input.addEventListener("blur", () => { void finish(true); }, { once: true }); + input.focus(); + input.select(); + } + + documentTitle?.addEventListener("click", event => { + if (!info?.can_rename) return; + event.stopPropagation(); + }); + documentTitle?.addEventListener("dblclick", event => { + if (!info?.can_rename) return; + event.preventDefault(); + event.stopPropagation(); + beginInlineTitleRename(); + }); + async function loadNoteInfo() { info = await adapter.loadInfo(sessionHeaders()); - const documentTitle = document.querySelector("#document-title"); if (documentTitle && info.title) { documentTitle.removeAttribute("data-i18n"); documentTitle.textContent = info.title; } + syncInlineTitleRename(); globalColor = info.global_color || ""; noteColor = info.note_color || ""; if (!getAuthToken()) noteColor = readGuestColor(); updateMarkdownFiles(info.files || []); @@ -336,8 +416,7 @@ export function startNoteEditor(adapter) { syncMobileEditorControls(); updateCurrentUser(); return info; } - // Presence is independent of document text, authorship spans and the gutter. - // Match by name, preserving duplicates and their DOM nodes between snapshots. + function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); @@ -506,6 +585,7 @@ export function startNoteEditor(adapter) { const renderVersion = ++mermaidRenderVersion; const nodes = [...preview.querySelectorAll(".mermaid")]; if (!nodes.length) return; + preview.querySelectorAll(".mermaid-error").forEach(error => error.remove()); try { nodes.forEach(node => { if (!node.dataset.mermaidSource) node.dataset.mermaidSource = node.textContent || ""; @@ -513,10 +593,12 @@ export function startNoteEditor(adapter) { const mermaid = await loadMermaid(); mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" }); await mermaid.run({ nodes }); + if (renderVersion === mermaidRenderVersion) preview.querySelectorAll(".mermaid-error").forEach(error => error.remove()); } catch { if (renderVersion !== mermaidRenderVersion) return; nodes.forEach(node => { if (!node.isConnected || !preview.contains(node) || !node.parentNode) return; + if (node.previousElementSibling?.classList.contains("mermaid-error")) return; const message = document.createElement("p"); message.className = "error mermaid-error"; message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid."); @@ -552,8 +634,6 @@ export function startNoteEditor(adapter) { reconcileChildren(participantBadges, nodes); } function syncOwnerLabels() { - // One composited transform instead of rewriting every author's position - // on every scroll event (important for thousands of distinct authors). const canvas = ownerLabels.firstElementChild; if (canvas?.classList.contains("owner-labels-canvas")) { canvas.style.transform = `translate3d(0, ${-editor.scrollTop}px, 0)`; @@ -2268,6 +2348,7 @@ export function startNoteEditor(adapter) { const documentLinkCopy = document.querySelector("#document-link-copy"); documentLinkCopy?.addEventListener("click", copyCurrentLink); documentLinkCopy?.addEventListener("keydown", event => { + if (event.target instanceof Element && event.target.closest(".document-title--editing, input, textarea")) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); copyCurrentLink(); @@ -2306,6 +2387,51 @@ export function startNoteEditor(adapter) { renderFavoriteButton(false); }); + function ensurePdfFormatButtons() { + const menu = document.querySelector(".markdown-more-menu"); + if (!menu) return; + const formats = [ + ["page-break", "editor.pageBreak", "Page break (PDF)"], + ["pdf-header", "editor.pdfHeaderTag", "PDF header"], + ["pdf-footer", "editor.pdfFooterTag", "PDF footer"], + ["pdf-align-top", "editor.pdfAlignTopTag", "PDF align top"], + ["pdf-align-center", "editor.pdfAlignCenterTag", "PDF align center"], + ]; + const tocButton = menu.querySelector('[data-format="toc"]'); + let insertAfter = tocButton; + for (const [format, key, fallback] of formats) { + let button = menu.querySelector(`[data-format="${format}"]`); + if (!button) { + button = document.createElement("button"); + button.type = "button"; + button.dataset.format = format; + button.dataset.i18n = key; + button.textContent = t(key, {}, fallback); + if (format === "page-break") { + button.dataset.i18nTitle = "editor.pageBreakShortcut"; + button.title = t("editor.pageBreakShortcut", {}, "Page break (PDF) · Ctrl/Cmd+Enter"); + } + if (insertAfter?.parentNode === menu) insertAfter.after(button); + else menu.prepend(button); + } + insertAfter = button; + } + + let separator = menu.querySelector(".markdown-more-separator"); + if (!separator) { + separator = document.createElement("div"); + separator.className = "markdown-more-separator"; + separator.setAttribute("role", "separator"); + separator.setAttribute("aria-hidden", "true"); + } + menu.append(separator); + for (const [format] of formats) { + const button = menu.querySelector(`[data-format="${format}"]`); + if (button) menu.append(button); + } + } + + ensurePdfFormatButtons(); let pendingPreviewFormatRange = null; document.querySelectorAll("[data-format]").forEach(button => { button.addEventListener("pointerdown", event => { diff --git a/static/js/pdf-export.js b/static/js/pdf-export.js index 7c2f0ec..8b6a627 100644 --- a/static/js/pdf-export.js +++ b/static/js/pdf-export.js @@ -128,6 +128,57 @@ function isBlankPrintLine(node) { return node.children.length === 1 && node.firstElementChild?.tagName === "BR"; } +function trimTrailingBlankPrintLines(root) { + let node = root.lastElementChild; + while (isBlankPrintLine(node)) { + const remove = node; + node = node.previousElementSibling; + remove.remove(); + } +} + +function makePdfLayoutMarker(doc, directive) { + const marker = doc.createElement("div"); + marker.className = "pdf-layout-marker"; + marker.dataset.pdfDirective = directive; + marker.setAttribute("aria-hidden", "true"); + return marker; +} + +function extractPdfLayout(root) { + const layout = { headerHtml: "", footerHtml: "", verticalAlign: null }; + const headers = []; + const footers = []; + + [...root.querySelectorAll(".pdf-layout-marker[data-pdf-directive]")].forEach(marker => { + const directive = String(marker.dataset.pdfDirective || "").toLowerCase(); + if (directive === "align_top" || directive === "align_center") { + layout.verticalAlign = directive === "align_center" ? "center" : "top"; + marker.remove(); + return; + } + + let block = marker.nextElementSibling; + while (isBlankPrintLine(block)) { + const remove = block; + block = block.nextElementSibling; + remove.remove(); + } + marker.remove(); + if (!block || block.classList?.contains("pdf-page-break")) return; + + block.remove(); + if (directive === "header") headers.push(block.outerHTML); + if (directive === "footer") footers.push(block.outerHTML); + }); + + root.querySelectorAll(".pdf-layout-marker").forEach(marker => marker.remove()); + trimTrailingBlankPrintLines(root); + layout.headerHtml = headers.join(""); + layout.footerHtml = footers.join(""); + return layout; +} + function collapseHeadingGaps(root) { root.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach(heading => { let next = heading.nextElementSibling; @@ -274,34 +325,47 @@ function preparePrintPagination(root) { } async function ensurePrintableMermaid(root, darkMode = false) { - const nodes = [...root.querySelectorAll(".mermaid[data-mermaid-source]")]; + const nodes = [...root.querySelectorAll(".mermaid")]; if (!nodes.length) return; + root.querySelectorAll(".mermaid-error").forEach(error => error.remove()); + const originals = nodes.map(node => ({ node, html: node.innerHTML, processed: node.getAttribute("data-processed"), + source: node.dataset.mermaidSource || node.textContent || "", + hadSvg: Boolean(node.querySelector("svg")), })); + const renderable = originals.filter(item => item.node.dataset.mermaidSource || !item.hadSvg); + if (!renderable.length) return; + const staging = document.createElement("div"); staging.setAttribute("aria-hidden", "true"); staging.style.cssText = "position:fixed;left:-200vw;top:0;width:1200px;visibility:hidden;pointer-events:none;z-index:-1"; try { const mermaid = await loadMermaid(); - nodes.forEach(node => { + renderable.forEach(({ node, source }) => { node.removeAttribute("data-processed"); - node.textContent = node.dataset.mermaidSource || ""; + node.textContent = source; }); staging.appendChild(root); document.body.appendChild(staging); mermaid.initialize({ startOnLoad: false, theme: darkMode ? "dark" : "default", securityLevel: "strict" }); - await mermaid.run({ nodes }); + await mermaid.run({ nodes: renderable.map(item => item.node) }); + root.querySelectorAll(".mermaid-error").forEach(error => error.remove()); } catch { - originals.forEach(({ node, html, processed }) => { + originals.forEach(({ node, html, processed, hadSvg }) => { node.innerHTML = html; if (processed === null) node.removeAttribute("data-processed"); else node.setAttribute("data-processed", processed); + if (hadSvg) return; + const message = document.createElement("p"); + message.className = "error mermaid-error"; + message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid."); + node.parentNode?.insertBefore(message, node); }); } finally { if (root.parentElement === staging) staging.removeChild(root); @@ -334,8 +398,10 @@ function measureLastPageFill(doc, documentRoot, pageContentHeight) { "z-index:-1", ].join(";"); - const title = documentRoot.querySelector(":scope > .pdf-document-title"); - if (title) probe.appendChild(title.cloneNode(true)); + for (const child of documentRoot.children) { + if (child === content) break; + if (child.matches?.(".pdf-document-title, .pdf-custom-header")) probe.appendChild(child.cloneNode(true)); + } const flow = content.cloneNode(true); flow.querySelectorAll(".pdf-page-break").forEach(pageBreak => { @@ -367,40 +433,82 @@ function fallbackLastPageFill(documentRoot, spacer, pageContentHeight) { function alignFooterToLastPage(doc, pageHeightMm, marginMm) { const documentRoot = doc.querySelector(".pdf-document"); const spacer = doc.querySelector(".pdf-footer-spacer"); - const footer = doc.querySelector(".pdf-generated-footer"); + const footer = doc.querySelector(".pdf-document-footer"); if (!documentRoot || !spacer || !footer) return; spacer.style.height = "0px"; - footer.style.removeProperty("margin-top"); + footer.style.marginTop = "0px"; const pxPerMm = mmToPx(doc); const pageContentHeight = (Number(pageHeightMm) - (2 * Number(marginMm))) * pxPerMm; const footerHeight = footer.getBoundingClientRect().height; - const footerMarginTop = Number.parseFloat(doc.defaultView?.getComputedStyle(footer).marginTop || "0") || 0; - const bottomGuard = 0.75 * pxPerMm; + const bottomGuard = Math.max(2, 1.5 * pxPerMm); + const minGap = 4 * pxPerMm; if (!Number.isFinite(pageContentHeight) || pageContentHeight <= footerHeight || footerHeight <= 0) return; const measuredFill = measureLastPageFill(doc, documentRoot, pageContentHeight); const positionOnPage = measuredFill ?? fallbackLastPageFill(documentRoot, spacer, pageContentHeight); const available = Math.max(0, pageContentHeight - positionOnPage); - let actualMarginTop = footerMarginTop; - let spacerHeight; + const spacerHeight = available >= footerHeight + minGap + bottomGuard + ? available - footerHeight - bottomGuard + : available + pageContentHeight - footerHeight - bottomGuard; - if (available >= footerHeight + bottomGuard) { - actualMarginTop = Math.min( - footerMarginTop, - Math.max(0, available - footerHeight - bottomGuard), - ); - spacerHeight = available - footerHeight - actualMarginTop - bottomGuard; - } else { - spacerHeight = available + pageContentHeight - footerHeight - actualMarginTop - bottomGuard; - } - - footer.style.marginTop = `${Math.max(0, actualMarginTop)}px`; spacer.style.height = `${Math.max(0, spacerHeight)}px`; } +function fitOversizedKeepUnits(doc, pageHeightMm, marginMm) { + const root = doc.querySelector(".pdf-content"); + if (!root) return; + const pxPerMm = mmToPx(doc); + const pageContentHeight = (Number(pageHeightMm) - (2 * Number(marginMm))) * pxPerMm; + const maxHeight = Math.max(40, pageContentHeight - Math.max(2, pxPerMm)); + + root.querySelectorAll(".pdf-keep-unit").forEach(unit => { + unit.classList.remove("pdf-keep-unit--oversized"); + let rect = unit.getBoundingClientRect(); + if (rect.height <= maxHeight) return; + + const scalable = unit.querySelector(".mermaid svg, img, .rustpad-media, video, canvas"); + if (scalable) { + const scalableRect = scalable.getBoundingClientRect(); + const overhead = Math.max(0, rect.height - scalableRect.height); + const targetHeight = Math.floor(maxHeight - overhead - 2); + if (targetHeight >= 48 && targetHeight < scalableRect.height) { + scalable.style.setProperty("max-height", `${targetHeight}px`, "important"); + scalable.style.setProperty("height", "auto", "important"); + } + rect = unit.getBoundingClientRect(); + } + + if (rect.height > maxHeight) unit.classList.add("pdf-keep-unit--oversized"); + }); +} + +function alignContentVertically(doc, pageHeightMm, marginMm, mode) { + const documentRoot = doc.querySelector(".pdf-document"); + const content = doc.querySelector(".pdf-content"); + const footer = doc.querySelector(".pdf-document-footer"); + if (!documentRoot || !content || !footer) return; + + content.style.marginTop = "0px"; + if (mode !== "center" || content.querySelector(".pdf-page-break")) return; + + const pxPerMm = mmToPx(doc); + const pageContentHeight = (Number(pageHeightMm) - (2 * Number(marginMm))) * pxPerMm; + const rootRect = documentRoot.getBoundingClientRect(); + const contentRect = content.getBoundingClientRect(); + const footerHeight = footer.getBoundingClientRect().height; + const headerHeight = Math.max(0, contentRect.top - rootRect.top); + const bottomGuard = Math.max(2, 1.5 * pxPerMm); + const minGap = 4 * pxPerMm; + const used = headerHeight + contentRect.height + footerHeight + minGap + bottomGuard; + if (!Number.isFinite(pageContentHeight) || used >= pageContentHeight) return; + + const freeSpace = pageContentHeight - headerHeight - footerHeight - bottomGuard - contentRect.height; + content.style.marginTop = `${Math.max(0, freeSpace / 2)}px`; +} + function preparePreview(preview) { const clone = preview.cloneNode(true); clone.removeAttribute("id"); @@ -426,11 +534,16 @@ function preparePreview(preview) { if (clone.classList.contains("preview--raw")) { clone.querySelectorAll(".preview-source-line").forEach(line => { - if (!/^\[PAGEBREAK\]$/i.test(line.textContent.trim())) return; - const pageBreak = document.createElement("div"); - pageBreak.className = "pdf-page-break"; - pageBreak.setAttribute("aria-hidden", "true"); - line.replaceWith(pageBreak); + const text = line.textContent.trim(); + if (/^\[(?:PDF_PAGEBREAK|PAGEBREAK)\]$/i.test(text)) { + const pageBreak = document.createElement("div"); + pageBreak.className = "pdf-page-break"; + pageBreak.setAttribute("aria-hidden", "true"); + line.replaceWith(pageBreak); + return; + } + const directive = text.match(/^\[PDF_(HEADER|FOOTER|ALIGN_TOP|ALIGN_CENTER)\]$/i); + if (directive) line.replaceWith(makePdfLayoutMarker(document, directive[1].toLowerCase())); }); } @@ -441,7 +554,7 @@ function preparePreview(preview) { marker.replaceWith(pageBreak); }); - preparePrintPagination(clone); + trimTrailingBlankPrintLines(clone); return clone; } @@ -483,8 +596,17 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) { background: ${pageBackground} !important; color: ${textColor} !important; } - .pdf-document-title { margin: 0 0 1.2em; padding: 0 0 .45em; border-bottom: 1px solid ${borderColor}; } + .pdf-document-title, + .pdf-custom-header { + margin: 0 0 1.2em; + padding: 0 0 .45em; + border-bottom: 1px solid ${borderColor}; + break-inside: avoid-page; + page-break-inside: avoid; + } .pdf-document-title h1 { margin: 0; font: 700 1.7rem/1.18 Inter, ui-sans-serif, system-ui, sans-serif; color: ${headingColor}; } + .pdf-custom-header > :first-child { margin-top: 0 !important; } + .pdf-custom-header > :last-child { margin-bottom: 0 !important; } .pdf-footer-spacer { display: block; width: 100%; @@ -493,9 +615,9 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) { padding: 0; border: 0; } - .pdf-generated-footer { + .pdf-document-footer { box-sizing: border-box; - margin-top: 8mm; + margin-top: 0; padding-top: 1.5mm; border-top: .2mm solid ${borderColor}; color: ${footerColor}; @@ -505,6 +627,8 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) { break-inside: avoid-page; page-break-inside: avoid; } + .pdf-custom-footer > :first-child { margin-top: 0 !important; } + .pdf-custom-footer > :last-child { margin-bottom: 0 !important; } .pdf-content { width: 100%; min-height: 0; @@ -521,20 +645,11 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) { .pdf-content a { color: inherit !important; text-decoration: underline; } /* Book-like pagination: keep small semantic units together, not whole sections. */ - /* - * Do not rely on break-after: avoid for the main Markdown headings. - * Chromium can first paint the heading at the end of the old page and - * then move it to the next page, leaving a clipped strip of glyphs behind. - * A small invisible reserve inside the heading keeps one following line - * with it without triggering that fragmentation bug. - */ .pdf-content h1, .pdf-content h2, .pdf-content h3 { break-after: auto; page-break-after: auto; - padding-bottom: 1.15em; - margin-bottom: calc(.18em - 1.15em); } .pdf-content h4, .pdf-content h5, @@ -637,16 +752,18 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) { } .pdf-content .pdf-keep-unit > :first-child { margin-top: 0 !important; } .pdf-content .pdf-keep-unit > :last-child { margin-bottom: 0 !important; } + .pdf-content .pdf-keep-unit.pdf-keep-unit--oversized { + display: block; + break-inside: auto; + page-break-inside: auto; + } .pdf-content .pdf-keep-short { break-inside: avoid-page !important; page-break-inside: avoid !important; } .pdf-content .pdf-heading-lead > h1, .pdf-content .pdf-heading-lead > h2, - .pdf-content .pdf-heading-lead > h3 { - padding-bottom: 0; - margin-bottom: .18em; - } + .pdf-content .pdf-heading-lead > h3 { margin-bottom: .18em; } .pdf-content .pdf-atomic-visual { display: inline-block; width: 100%; @@ -819,7 +936,7 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) { `; } -function openPrintWindow({ preview, title, formatKey, marginMm, includeTitle, darkMode }) { +function openPrintWindow({ preview, title, formatKey, marginMm, includeTitle, darkMode, verticalAlign }) { const format = PAGE_FORMATS[formatKey] || PAGE_FORMATS["a4-portrait"]; const safeMargin = [0, 8, 12, 20].includes(Number(marginMm)) ? Number(marginMm) : 12; const printWindow = window.open("", "_blank"); @@ -827,26 +944,39 @@ function openPrintWindow({ preview, title, formatKey, marginMm, includeTitle, da try { printWindow.opener = null; } catch { /* same-origin hardening is best effort */ } const content = preparePreview(preview); + const pdfLayout = extractPdfLayout(content); + const resolvedVerticalAlign = pdfLayout.verticalAlign || (verticalAlign === "center" ? "center" : "top"); const titleBlock = includeTitle && title ? `

${escapeHtml(title)}

` : ""; + const customHeader = pdfLayout.headerHtml + ? `
${pdfLayout.headerHtml}
` + : ""; const lang = escapeHtml(document.documentElement.lang || "en"); const base = escapeHtml(`${location.origin}/`); const css = printStyles(format.cssSize, format.widthMm, format.heightMm, safeMargin, darkMode); const footerText = escapeHtml(t("editor.pdfGeneratedFooter", {}, "Generated by RustPad")); + const footerBlock = pdfLayout.footerHtml + ? `
${pdfLayout.footerHtml}
` + : `
${footerText}
`; return (async () => { await ensurePrintableMermaid(content, darkMode); await ensureSyntaxHighlight(content); + preparePrintPagination(content); printWindow.document.open(); - printWindow.document.write(`${escapeHtml(title || "RustPad")}${stylesheetLinks()}
${titleBlock}${content.outerHTML}
${footerText}
`); + printWindow.document.write(`${escapeHtml(title || "RustPad")}${stylesheetLinks()}
${customHeader}${titleBlock}${content.outerHTML}${footerBlock}
`); printWindow.document.close(); const runPrint = async () => { await waitForPrintableLayout(printWindow.document); + fitOversizedKeepUnits(printWindow.document, format.heightMm, safeMargin); + alignContentVertically(printWindow.document, format.heightMm, safeMargin, resolvedVerticalAlign); alignFooterToLastPage(printWindow.document, format.heightMm, safeMargin); await nextFrame(printWindow); + fitOversizedKeepUnits(printWindow.document, format.heightMm, safeMargin); + alignContentVertically(printWindow.document, format.heightMm, safeMargin, resolvedVerticalAlign); alignFooterToLastPage(printWindow.document, format.heightMm, safeMargin); await nextFrame(printWindow); printWindow.focus(); @@ -868,6 +998,7 @@ export function bindPdfExport({ button, dialog, form, preview, title }) { if (!button || !dialog || !form || !preview) return; const formatSelect = form.querySelector("#pdf-page-format"); const marginSelect = form.querySelector("#pdf-page-margin"); + const verticalAlignSelect = form.querySelector("#pdf-vertical-align"); const darkMode = form.querySelector("#pdf-dark-mode"); const includeTitle = form.querySelector("#pdf-include-title"); const cancelButtons = form.querySelectorAll("[data-pdf-cancel]"); @@ -878,6 +1009,10 @@ export function bindPdfExport({ button, dialog, form, preview, title }) { const savedMargin = saved("margin", "12"); marginSelect.value = ["0", "8", "12", "20"].includes(savedMargin) ? savedMargin : "12"; } + if (verticalAlignSelect) { + const savedVerticalAlign = saved("vertical-align", "top"); + verticalAlignSelect.value = ["top", "center"].includes(savedVerticalAlign) ? savedVerticalAlign : "top"; + } if (darkMode) darkMode.checked = saved("dark-mode", "on") !== "off"; includeTitle.checked = saved("include-title", "off") === "on"; @@ -891,6 +1026,7 @@ export function bindPdfExport({ button, dialog, form, preview, title }) { const marginMm = Number(marginSelect?.value ?? 12); save("format", formatKey); save("margin", marginMm); + save("vertical-align", verticalAlignSelect?.value === "center" ? "center" : "top"); save("dark-mode", darkMode?.checked === false ? "off" : "on"); save("include-title", includeTitle.checked ? "on" : "off"); try { @@ -901,6 +1037,7 @@ export function bindPdfExport({ button, dialog, form, preview, title }) { marginMm, includeTitle: includeTitle.checked, darkMode: darkMode?.checked !== false, + verticalAlign: verticalAlignSelect?.value === "center" ? "center" : "top", }); dialog.close("confirm"); void printJob.catch(error => { diff --git a/static/js/public.js b/static/js/public.js index bf5e9eb..eb98de5 100644 --- a/static/js/public.js +++ b/static/js/public.js @@ -37,6 +37,7 @@ async function renderMermaid() { const renderVersion = ++mermaidRenderVersion; const nodes = [...content.querySelectorAll(".mermaid")]; if (!nodes.length) return; + content.querySelectorAll(".mermaid-error").forEach(error => error.remove()); try { nodes.forEach(node => { if (!node.dataset.mermaidSource) node.dataset.mermaidSource = node.textContent || ""; @@ -44,10 +45,12 @@ async function renderMermaid() { const mermaid = await loadMermaid(); mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" }); await mermaid.run({ nodes }); + if (renderVersion === mermaidRenderVersion) content.querySelectorAll(".mermaid-error").forEach(error => error.remove()); } catch { if (renderVersion !== mermaidRenderVersion) return; nodes.forEach(node => { if (!node.isConnected || !content.contains(node) || !node.parentNode) return; + if (node.previousElementSibling?.classList.contains("mermaid-error")) return; const message = document.createElement("p"); message.className = "error mermaid-error"; message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid."); diff --git a/static/js/vendor-libs.js b/static/js/vendor-libs.js index 6ae1fa3..60a593a 100644 --- a/static/js/vendor-libs.js +++ b/static/js/vendor-libs.js @@ -19,8 +19,13 @@ function localAsset(path) { } function once(key, factory) { - if (!promiseCache.has(key)) promiseCache.set(key, Promise.resolve().then(factory)); - return promiseCache.get(key); + if (promiseCache.has(key)) return promiseCache.get(key); + const promise = Promise.resolve().then(factory); + promiseCache.set(key, promise); + promise.catch(() => { + if (promiseCache.get(key) === promise) promiseCache.delete(key); + }); + return promise; } function loadClassicScript(path, globalName) { diff --git a/static/js/workspace.js b/static/js/workspace.js index 6e16c37..60ba491 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -58,6 +58,15 @@ const notesViewKey = `rustpad:workspace:${slug}:notes-view`; let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid"; let notesCache = []; let archivedNotesCache = []; +let noteTitleOpenTimer = 0; + +document.addEventListener("pointerdown", event => { + if (!noteTitleOpenTimer) return; + const target = event.target instanceof Element ? event.target : null; + if (target?.closest("[data-note-rename]")) return; + clearTimeout(noteTitleOpenTimer); + noteTitleOpenTimer = 0; +}, true); function updateWorkspacePasswordControl() { workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password); @@ -266,8 +275,6 @@ async function toggleNoteFavorite(button) { { title: t("favorites.title", {}, "Favorites") }, ); } catch (error) { - // The API client already rechecks the session after a 401. - // Only a 403 needs an additional check for an unrelated access restriction. if (error.status === 403) { try { const session = await validateCurrentSession(); @@ -296,7 +303,7 @@ function renderNotes(notes = notesCache) { notesList.innerHTML = `
${favoriteHeader}${notes.map(note => ` ${accountSession ? `` : ""} - + @@ -310,13 +317,70 @@ function renderNotes(notes = notesCache) { notesList.innerHTML = notes.map(note => ` `).join(""); } +function beginWorkspaceNoteRename(target, note) { + if (!target || !note?.can_rename || target.querySelector("input")) return; + const previous = note.title; + const input = document.createElement("input"); + input.className = "note-title-inline-input"; + input.type = "text"; + input.maxLength = 80; + input.value = previous; + input.setAttribute("aria-label", t("workspace.renameLabel", {}, "Note name")); + target.replaceChildren(input); + target.classList.add("note-list-inline-title--editing"); + let finished = false; + + const restore = title => { + if (!target.isConnected) return; + target.classList.remove("note-list-inline-title--editing"); + target.textContent = title; + }; + const finish = async save => { + if (finished) return; + finished = true; + const next = input.value.trim(); + if (!save) { restore(previous); return; } + if (!next) { + restore(previous); + toast.danger(t("workspace.renameEmpty", {}, "The note name cannot be empty."), { title: t("workspace.renameFailed", {}, "Could not rename note") }); + return; + } + if (next === previous) { restore(previous); return; } + input.disabled = true; + try { + const result = await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(note.slug)}/rename`, { + method: "POST", + body: JSON.stringify({ name: next, access_token: accessToken || null }), + }); + note.title = result.title || next; + note.updated_at = result.updated_at || note.updated_at; + restore(note.title); + toast.success(t("workspace.renamed", { title: note.title }, `Renamed to “${note.title}”.`), { title: t("workspace.renamedTitle", {}, "Note renamed") }); + await openWorkspace(); + } catch (error) { + restore(previous); + toast.danger(error.message, { title: t("workspace.renameFailed", {}, "Could not rename note") }); + } + }; + + input.addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); }); + input.addEventListener("dblclick", event => { event.preventDefault(); event.stopPropagation(); }); + input.addEventListener("keydown", event => { + if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); void finish(true); } + else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); void finish(false); } + }); + input.addEventListener("blur", () => { void finish(true); }, { once: true }); + input.focus(); + input.select(); +} + async function showSystemNotFound() { try { const response = await fetch(`${location.pathname.replace(/\/$/, "")}/__not_found__`, { @@ -519,6 +583,35 @@ document.querySelector("#note-form").addEventListener("submit", async e => { } catch (err) { error.textContent = err.message; toast.danger(err.message, { title: "Could not create note" }); } }); notesList.addEventListener("click", async event => { + const activeInput = event.target.closest(".note-title-inline-input"); + if (activeInput) { + event.preventDefault(); + event.stopPropagation(); + return; + } + const editingTitle = notesList.querySelector(".note-list-inline-title--editing"); + if (editingTitle) { + event.preventDefault(); + return; + } + const renameTarget = event.target.closest("[data-note-rename]"); + if (renameTarget) { + const note = notesCache.find(item => item.slug === renameTarget.dataset.noteRename); + if (note?.can_rename) { + event.preventDefault(); + clearTimeout(noteTitleOpenTimer); + if (event.detail >= 2) { + noteTitleOpenTimer = 0; + beginWorkspaceNoteRename(renameTarget, note); + } else { + noteTitleOpenTimer = window.setTimeout(() => { + noteTitleOpenTimer = 0; + location.assign(safeAppUrl(note.url)); + }, 400); + } + return; + } + } const favorite = event.target.closest("[data-favorite-note]"); if (favorite) { event.preventDefault();
NameCreated byParticipantsFilesRevisionsStatusUpdatedActions
${favoriteButton(note, true)}${escapeHtml(note.title)}${escapeHtml(note.title)} ${escapeHtml(note.created_by || "Unknown")} ${Number(note.participant_count) || 0} ${Number(note.file_count) || 0} (${formatBytes(note.file_size_bytes)})