v0.2.96
This commit is contained in:
1 parent
b15a6c54ce
commit
d36a4b2451
22 files changed
+926
-114
No files matched your search
Generated
+1
-1
@@ -2674,7 +2674,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.95"
|
version = "0.2.96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.95"
|
version = "0.2.96"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.94"
|
rust-version = "1.94"
|
||||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||||
|
|||||||
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
RustPad is a collaborative Markdown editor with standalone notes and workspaces.
|
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
|
## Release 0.2.95 — dark PDF export
|
||||||
|
|
||||||
- PDF export now includes a dark-mode checkbox enabled by default.
|
- 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.
|
- 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.
|
- Indented fenced code blocks shift their whole preview block rather than just the code text.
|
||||||
- Optional line numbers in fenced code blocks.
|
- 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
|
## 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.
|
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
|
## Fenced code blocks and language aliases
|
||||||
|
|
||||||
|
|||||||
@@ -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<PathBuf>) {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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() {
|
fn main() {
|
||||||
println!("cargo:rerun-if-changed=lang");
|
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 manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
|
||||||
let lang_dir = manifest_dir.join("lang");
|
let lang_dir = manifest_dir.join("lang");
|
||||||
@@ -31,5 +88,11 @@ fn main() {
|
|||||||
generated.push_str("];\n");
|
generated.push_str("];\n");
|
||||||
|
|
||||||
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
|
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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -13,8 +13,7 @@ export UPLOAD_MAX_SIZE_MB="${UPLOAD_MAX_SIZE_MB:-20}"
|
|||||||
export STATIC_DIR="${STATIC_DIR:-$(pwd)/static}"
|
export STATIC_DIR="${STATIC_DIR:-$(pwd)/static}"
|
||||||
export RUST_LOG="${RUST_LOG:-rustpad=debug,tower_http=info}"
|
export RUST_LOG="${RUST_LOG:-rustpad=debug,tower_http=info}"
|
||||||
|
|
||||||
# Generate a new asset version on each run to prevent stale HTML and JavaScript.
|
# Asset URLs are cache-busted by a build-time content hash.
|
||||||
export ASSET_VERSION="${ASSET_VERSION:-dev-$(date +%s)}"
|
|
||||||
|
|
||||||
update_browser_libs() {
|
update_browser_libs() {
|
||||||
local python_bin=""
|
local python_bin=""
|
||||||
|
|||||||
+22
-2
@@ -387,13 +387,17 @@
|
|||||||
"editor.pageBreak": "Page break (PDF)",
|
"editor.pageBreak": "Page break (PDF)",
|
||||||
"editor.pageBreakShortcut": "Page break (PDF) · Ctrl/Cmd+Enter",
|
"editor.pageBreakShortcut": "Page break (PDF) · Ctrl/Cmd+Enter",
|
||||||
"editor.pageFormat": "Page format",
|
"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.darkPdf": "Dark PDF",
|
||||||
"editor.pdfPrintHelp": "The browser print dialog will open. Choose Save as 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.pdfGeneratedFooter": "Generated by RustPad",
|
||||||
"editor.presentation16x9": "16:9 — Presentation",
|
"editor.presentation16x9": "16:9 — Presentation",
|
||||||
"editor.presentation4x3": "4:3 — Presentation",
|
"editor.presentation4x3": "4:3 — Presentation",
|
||||||
"editor.wideMargin": "Wide (20 mm)",
|
"editor.wideMargin": "Wide (20 mm)",
|
||||||
|
"editor.verticalContent": "Vertical content",
|
||||||
|
"editor.verticalTop": "Top",
|
||||||
|
"editor.verticalCenterSingle": "Centered (single page)",
|
||||||
"editor.failedMermaid": "Failed to load Mermaid.",
|
"editor.failedMermaid": "Failed to load Mermaid.",
|
||||||
"editor.fileCount": "{count} files",
|
"editor.fileCount": "{count} files",
|
||||||
"editor.font": "Font",
|
"editor.font": "Font",
|
||||||
@@ -1017,6 +1021,22 @@
|
|||||||
"files.link": "Link",
|
"files.link": "Link",
|
||||||
"files.markdown": "Markdown",
|
"files.markdown": "Markdown",
|
||||||
"home.account.loading": "Checking your account...",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+22
-2
@@ -387,13 +387,17 @@
|
|||||||
"editor.pageBreak": "Podział strony (PDF)",
|
"editor.pageBreak": "Podział strony (PDF)",
|
||||||
"editor.pageBreakShortcut": "Podział strony (PDF) · Ctrl/Cmd+Enter",
|
"editor.pageBreakShortcut": "Podział strony (PDF) · Ctrl/Cmd+Enter",
|
||||||
"editor.pageFormat": "Format strony",
|
"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.darkPdf": "Ciemny PDF",
|
||||||
"editor.pdfPrintHelp": "Otworzy się okno drukowania przeglądarki. Wybierz Zapisz jako 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.pdfGeneratedFooter": "Wygenerowano przez RustPad",
|
||||||
"editor.presentation16x9": "16:9 — prezentacja",
|
"editor.presentation16x9": "16:9 — prezentacja",
|
||||||
"editor.presentation4x3": "4:3 — prezentacja",
|
"editor.presentation4x3": "4:3 — prezentacja",
|
||||||
"editor.wideMargin": "Szerokie (20 mm)",
|
"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.failedMermaid": "Nie udało się załadować Mermaid.",
|
||||||
"editor.fileCount": "{count} plików",
|
"editor.fileCount": "{count} plików",
|
||||||
"editor.font": "Czcionka",
|
"editor.font": "Czcionka",
|
||||||
@@ -1017,6 +1021,22 @@
|
|||||||
"files.link": "Link",
|
"files.link": "Link",
|
||||||
"files.markdown": "Markdown",
|
"files.markdown": "Markdown",
|
||||||
"home.account.loading": "Sprawdzanie konta...",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+118
-17
@@ -193,6 +193,25 @@ async fn can_manage_workspace_settings(
|
|||||||
account_owner || workspace_creator_is_requester(headers, workspace)
|
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<bool, ApiError> {
|
||||||
|
Ok(request_access_level(
|
||||||
|
state,
|
||||||
|
headers,
|
||||||
|
kind,
|
||||||
|
slug,
|
||||||
|
supplied_access_token,
|
||||||
|
bearer_token(headers),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
>= AccessLevel::Write)
|
||||||
|
}
|
||||||
|
|
||||||
async fn has_write_permission(
|
async fn has_write_permission(
|
||||||
state: &SharedState,
|
state: &SharedState,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
@@ -363,6 +382,21 @@ pub struct CreateNoteRequest {
|
|||||||
created_by: Option<String>,
|
created_by: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RenameRequest {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
password: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
access_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RenameResponse {
|
||||||
|
title: String,
|
||||||
|
updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct SetWorkspacePasswordRequest {
|
pub struct SetWorkspacePasswordRequest {
|
||||||
password: String,
|
password: String,
|
||||||
@@ -439,6 +473,7 @@ fn normalize_list_per_page(value: usize) -> usize {
|
|||||||
pub struct NoteListItem {
|
pub struct NoteListItem {
|
||||||
slug: String,
|
slug: String,
|
||||||
title: String,
|
title: String,
|
||||||
|
can_rename: bool,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
url: String,
|
url: String,
|
||||||
@@ -469,6 +504,7 @@ pub struct NoteInfo {
|
|||||||
workspace_title: String,
|
workspace_title: String,
|
||||||
slug: String,
|
slug: String,
|
||||||
title: String,
|
title: String,
|
||||||
|
can_rename: bool,
|
||||||
protected: bool,
|
protected: bool,
|
||||||
access_level: String,
|
access_level: String,
|
||||||
note_protected: bool,
|
note_protected: bool,
|
||||||
@@ -695,14 +731,8 @@ pub async fn create_workspace(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let workspace = db::create_workspace(
|
let workspace =
|
||||||
&state.db,
|
db::create_workspace(&state.db, &slug, title, password, created_by_guest_id).await?;
|
||||||
&slug,
|
|
||||||
title,
|
|
||||||
password,
|
|
||||||
created_by_guest_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if let Some(user) = account_user {
|
if let Some(user) = account_user {
|
||||||
sqlx::query(queries::get(
|
sqlx::query(queries::get(
|
||||||
state.db.kind(),
|
state.db.kind(),
|
||||||
@@ -772,12 +802,8 @@ pub async fn set_workspace_settings(
|
|||||||
"Only the workspace owner can change workspace settings.",
|
"Only the workspace owner can change workspace settings.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
db::set_workspace_archive_deleted_notes(
|
db::set_workspace_archive_deleted_notes(&state.db, workspace.id, payload.archive_deleted_notes)
|
||||||
&state.db,
|
.await?;
|
||||||
workspace.id,
|
|
||||||
payload.archive_deleted_notes,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let workspace = db::find_workspace(&state.db, &workspace_slug)
|
let workspace = db::find_workspace(&state.db, &workspace_slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_workspace)?;
|
.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.",
|
"Only the workspace owner can set its password.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let except_client_id =
|
let except_client_id = crate::websocket::clean_collaboration_client_id(payload.client_id);
|
||||||
crate::websocket::clean_collaboration_client_id(payload.client_id);
|
|
||||||
let password = validate_password(Some(payload.password.as_str()))?
|
let password = validate_password(Some(payload.password.as_str()))?
|
||||||
.ok_or_else(|| ApiError::bad_request("Password is required."))?;
|
.ok_or_else(|| ApiError::bad_request("Password is required."))?;
|
||||||
db::set_workspace_password(&state.db, &workspace_slug, password).await?;
|
db::set_workspace_password(&state.db, &workspace_slug, password).await?;
|
||||||
@@ -902,6 +927,21 @@ pub async fn open_workspace(
|
|||||||
&headers,
|
&headers,
|
||||||
)
|
)
|
||||||
.await?;
|
.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(
|
let mut access_level = effective_header_access_level(
|
||||||
&state,
|
&state,
|
||||||
&headers,
|
&headers,
|
||||||
@@ -911,7 +951,7 @@ pub async fn open_workspace(
|
|||||||
workspace.password_hash.is_some(),
|
workspace.password_hash.is_some(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if db::verify_workspace_password(&workspace, payload.password.as_deref()) {
|
if password_write_access {
|
||||||
access_level = AccessLevel::Write;
|
access_level = AccessLevel::Write;
|
||||||
}
|
}
|
||||||
let workspace_account_owner = crate::auth::is_resource_owner(
|
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),
|
url: format!("/w/{}/n/{}", workspace.slug, note.slug),
|
||||||
slug: note.slug,
|
slug: note.slug,
|
||||||
title: note.title,
|
title: note.title,
|
||||||
|
can_rename: workspace_owner
|
||||||
|
|| note_owner
|
||||||
|
|| password_write_access
|
||||||
|
|| explicit_write_access,
|
||||||
created_at: db::normalize_timestamp(¬e.created_at),
|
created_at: db::normalize_timestamp(¬e.created_at),
|
||||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||||
protected: note.protected,
|
protected: note.protected,
|
||||||
@@ -1118,6 +1162,7 @@ pub async fn create_note(
|
|||||||
url: format!("/w/{workspace_slug}/n/{slug}"),
|
url: format!("/w/{workspace_slug}/n/{slug}"),
|
||||||
slug: note.slug,
|
slug: note.slug,
|
||||||
title: note.title,
|
title: note.title,
|
||||||
|
can_rename: true,
|
||||||
created_at: db::normalize_timestamp(¬e.created_at),
|
created_at: db::normalize_timestamp(¬e.created_at),
|
||||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||||
protected: note.protected,
|
protected: note.protected,
|
||||||
@@ -1132,6 +1177,59 @@ pub async fn create_note(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn rename_note(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||||
|
Json(payload): Json<RenameRequest>,
|
||||||
|
) -> Result<Json<RenameResponse>, 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<Option<String>, ApiError> {
|
fn clean_editor_color(value: Option<&str>) -> Result<Option<String>, ApiError> {
|
||||||
let Some(value) = value else {
|
let Some(value) = value else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -1263,6 +1361,8 @@ pub async fn note_info(
|
|||||||
let password_write_access =
|
let password_write_access =
|
||||||
has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
|
has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
|
||||||
let requester_is_owner = workspace_owner || workspace_guest_owner || note_owner;
|
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(
|
let can_manage_authorship = can_manage_resource_settings(
|
||||||
workspace_owner || workspace_guest_owner,
|
workspace_owner || workspace_guest_owner,
|
||||||
note_owner,
|
note_owner,
|
||||||
@@ -1288,6 +1388,7 @@ pub async fn note_info(
|
|||||||
workspace_title: workspace.title,
|
workspace_title: workspace.title,
|
||||||
slug: note.slug,
|
slug: note.slug,
|
||||||
title: note.title,
|
title: note.title,
|
||||||
|
can_rename: requester_is_owner || explicit_rename_access,
|
||||||
protected: workspace.password_hash.is_some(),
|
protected: workspace.password_hash.is_some(),
|
||||||
access_level: access_level_name(access_level).into(),
|
access_level: access_level_name(access_level).into(),
|
||||||
note_protected: note.protected,
|
note_protected: note.protected,
|
||||||
|
|||||||
+53
-6
@@ -35,6 +35,7 @@ pub struct CreatePadResponse {
|
|||||||
pub struct PadInfo {
|
pub struct PadInfo {
|
||||||
slug: String,
|
slug: String,
|
||||||
title: String,
|
title: String,
|
||||||
|
can_rename: bool,
|
||||||
protected: bool,
|
protected: bool,
|
||||||
access_level: String,
|
access_level: String,
|
||||||
allow_public_task_updates: bool,
|
allow_public_task_updates: bool,
|
||||||
@@ -139,6 +140,8 @@ pub async fn pad_info(
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let guest_owner = pad_creator_is_requester(&headers, &pad);
|
let guest_owner = pad_creator_is_requester(&headers, &pad);
|
||||||
let password_write_access = has_password_write_access(&state, &headers, "pad", &slug).await?;
|
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 =
|
let can_manage_authorship =
|
||||||
can_manage_resource_settings(account_owner, guest_owner, password_write_access);
|
can_manage_resource_settings(account_owner, guest_owner, password_write_access);
|
||||||
let upload_max_size_bytes = resource_upload_limit(&state, &headers, "pad", &slug).await?;
|
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 {
|
Ok(Json(PadInfo {
|
||||||
slug: pad.slug,
|
slug: pad.slug,
|
||||||
title: pad.title,
|
title: pad.title,
|
||||||
|
can_rename: guest_owner || explicit_rename_access,
|
||||||
protected: pad.password_hash.is_some(),
|
protected: pad.password_hash.is_some(),
|
||||||
access_level: access_level_name(access_level).into(),
|
access_level: access_level_name(access_level).into(),
|
||||||
allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?,
|
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<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
Json(payload): Json<RenameRequest>,
|
||||||
|
) -> Result<Json<RenameResponse>, 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(
|
pub async fn set_pad_password(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -202,15 +247,17 @@ pub async fn set_pad_password(
|
|||||||
if pad.password_hash.is_some() {
|
if pad.password_hash.is_some() {
|
||||||
return Err(ApiError::bad_request("This note already has a password."));
|
return Err(ApiError::bad_request("This note already has a password."));
|
||||||
}
|
}
|
||||||
let account_owner = crate::auth::is_resource_owner(
|
let account_owner =
|
||||||
&state, "pad", &slug, user_session_token(&headers),
|
crate::auth::is_resource_owner(&state, "pad", &slug, user_session_token(&headers))
|
||||||
).await.unwrap_or(false);
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
let guest_owner = pad_creator_is_requester(&headers, &pad);
|
let guest_owner = pad_creator_is_requester(&headers, &pad);
|
||||||
if !can_set_resource_password(pad.password_hash.is_some(), account_owner, guest_owner) {
|
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 =
|
let except_client_id = crate::websocket::clean_collaboration_client_id(payload.client_id);
|
||||||
crate::websocket::clean_collaboration_client_id(payload.client_id);
|
|
||||||
let password = validate_password(Some(payload.password.as_str()))?
|
let password = validate_password(Some(payload.password.as_str()))?
|
||||||
.ok_or_else(|| ApiError::bad_request("Password is required."))?;
|
.ok_or_else(|| ApiError::bad_request("Password is required."))?;
|
||||||
db::set_pad_password(&state.db, &slug, password).await?;
|
db::set_pad_password(&state.db, &slug, password).await?;
|
||||||
|
|||||||
+11
-8
@@ -161,6 +161,7 @@ pub fn router(
|
|||||||
.route("/api/public/{token}/tasks", post(api::update_public_task))
|
.route("/api/public/{token}/tasks", post(api::update_public_task))
|
||||||
.route("/api/pads", post(api::create_pad))
|
.route("/api/pads", post(api::create_pad))
|
||||||
.route("/api/pads/{slug}", get(api::pad_info))
|
.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}/history", post(api::pad_history))
|
||||||
.route(
|
.route(
|
||||||
"/api/pads/{slug}/editor-color",
|
"/api/pads/{slug}/editor-color",
|
||||||
@@ -211,6 +212,10 @@ pub fn router(
|
|||||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}",
|
"/api/workspaces/{workspace_slug}/notes/{note_slug}",
|
||||||
get(api::note_info).delete(api::delete_note),
|
get(api::note_info).delete(api::delete_note),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/workspaces/{workspace_slug}/notes/{note_slug}/rename",
|
||||||
|
post(api::rename_note),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-color",
|
"/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-color",
|
||||||
get(api::note_editor_color).post(api::set_note_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 {
|
async fn language_catalog() -> Response {
|
||||||
let mut response = Json(crate::i18n::metadata()).into_response();
|
let mut response = Json(crate::i18n::metadata()).into_response();
|
||||||
response.headers_mut().insert(
|
response
|
||||||
header::CACHE_CONTROL,
|
.headers_mut()
|
||||||
HeaderValue::from_static("public, max-age=300"),
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||||
);
|
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,10 +311,9 @@ async fn language_bundle(Path(code): Path<String>) -> Response {
|
|||||||
header::CONTENT_TYPE,
|
header::CONTENT_TYPE,
|
||||||
HeaderValue::from_static("application/json; charset=utf-8"),
|
HeaderValue::from_static("application/json; charset=utf-8"),
|
||||||
);
|
);
|
||||||
response.headers_mut().insert(
|
response
|
||||||
header::CACHE_CONTROL,
|
.headers_mut()
|
||||||
HeaderValue::from_static("public, max-age=86400"),
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||||
);
|
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -181,7 +181,9 @@ impl Config {
|
|||||||
"GUEST_UPLOAD_MAX_SIZE_MB",
|
"GUEST_UPLOAD_MAX_SIZE_MB",
|
||||||
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)?,
|
http_compression: values.bool("HTTP_COMPRESSION", false)?,
|
||||||
asset_cache_max_age_seconds: values
|
asset_cache_max_age_seconds: values
|
||||||
.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
|
.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
|
||||||
|
|||||||
@@ -442,6 +442,36 @@ pub async fn create_note(
|
|||||||
.ok_or(sqlx::Error::RowNotFound)
|
.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(
|
pub async fn save_revision(
|
||||||
pool: &Database,
|
pool: &Database,
|
||||||
note_id: i64,
|
note_id: i64,
|
||||||
@@ -650,6 +680,25 @@ pub async fn create_pad(
|
|||||||
.await
|
.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(
|
pub async fn set_pad_password(
|
||||||
pool: &Database,
|
pool: &Database,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
|
|||||||
@@ -655,6 +655,53 @@ textarea:focus {
|
|||||||
text-overflow: ellipsis;
|
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 {
|
.document-url {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
@@ -1129,6 +1176,10 @@ textarea::selection {
|
|||||||
border-top-style: dashed;
|
border-top-style: dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-body .pdf-layout-marker {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-footer {
|
.editor-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3829,6 +3880,36 @@ dialog::backdrop {
|
|||||||
text-decoration: underline;
|
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 {
|
.note-status {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: .75rem;
|
font-size: .75rem;
|
||||||
@@ -4338,6 +4419,9 @@ dialog::backdrop {
|
|||||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
width: 290px;
|
width: 290px;
|
||||||
|
max-height: min(70vh, 560px);
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
@@ -4350,6 +4434,13 @@ dialog::backdrop {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-more-separator {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px 2px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
.hljs {
|
.hljs {
|
||||||
color: var(--dialog-text);
|
color: var(--dialog-text);
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-4
@@ -127,9 +127,18 @@
|
|||||||
data-format="definition">Definition</button><button type="button"
|
data-format="definition">Definition</button><button type="button"
|
||||||
data-format="highlight">Highlight</button><button type="button"
|
data-format="highlight">Highlight</button><button type="button"
|
||||||
data-format="subscript">Subscript</button><button type="button"
|
data-format="subscript">Subscript</button><button type="button"
|
||||||
data-format="superscript">Superscript</button><button type="button"
|
data-format="superscript">Superscript</button>
|
||||||
data-format="page-break" title="Page break (PDF) · Ctrl/Cmd+Enter">Page break
|
<div class="markdown-more-separator" role="separator" aria-hidden="true"></div>
|
||||||
(PDF)</button></div>
|
<button type="button" data-format="page-break" data-i18n="editor.pageBreak"
|
||||||
|
data-i18n-title="editor.pageBreakShortcut"
|
||||||
|
title="Page break (PDF) · Ctrl/Cmd+Enter">Page break (PDF)</button><button type="button"
|
||||||
|
data-format="pdf-header" data-i18n="editor.pdfHeaderTag">PDF header</button><button
|
||||||
|
type="button" data-format="pdf-footer" data-i18n="editor.pdfFooterTag">PDF
|
||||||
|
footer</button><button type="button" data-format="pdf-align-top"
|
||||||
|
data-i18n="editor.pdfAlignTopTag">PDF align top</button><button type="button"
|
||||||
|
data-format="pdf-align-center" data-i18n="editor.pdfAlignCenterTag">PDF align
|
||||||
|
center</button>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<button id="mobile-upload-button" class="mobile-upload-button" type="button" title="Upload a file"><span
|
<button id="mobile-upload-button" class="mobile-upload-button" type="button" title="Upload a file"><span
|
||||||
@@ -321,6 +330,11 @@
|
|||||||
<option value="12" selected>Normal (12 mm)</option>
|
<option value="12" selected>Normal (12 mm)</option>
|
||||||
<option value="20">Wide (20 mm)</option>
|
<option value="20">Wide (20 mm)</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
|
<label><span data-i18n="editor.verticalContent">Vertical content</span><select class="app-select-input"
|
||||||
|
id="pdf-vertical-align">
|
||||||
|
<option value="top" selected data-i18n="editor.verticalTop">Top</option>
|
||||||
|
<option value="center" data-i18n="editor.verticalCenterSingle">Centered (single page)</option>
|
||||||
|
</select></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="pdf-export-options">
|
<div class="pdf-export-options">
|
||||||
<label class="public-task-toggle pdf-export-dark-toggle"><input id="pdf-dark-mode" type="checkbox"
|
<label class="public-task-toggle pdf-export-dark-toggle"><input id="pdf-dark-mode" type="checkbox"
|
||||||
@@ -328,7 +342,10 @@
|
|||||||
<label class="public-task-toggle pdf-export-title-toggle"><input id="pdf-include-title" type="checkbox">
|
<label class="public-task-toggle pdf-export-title-toggle"><input id="pdf-include-title" type="checkbox">
|
||||||
Include document title</label>
|
Include document title</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="pdf-export-help">The browser print dialog will open. Choose Save as PDF.</p>
|
<p class="pdf-export-help"><span data-i18n="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.</span> <span data-i18n="editor.pdfPrintHelp">The
|
||||||
|
browser print dialog will open. Choose Save as PDF.</span></p>
|
||||||
<div class="dialog-actions"><button class="secondary-button" value="cancel" type="button"
|
<div class="dialog-actions"><button class="secondary-button" value="cancel" type="button"
|
||||||
data-pdf-cancel>Cancel</button><button id="generate-pdf" class="primary-button"
|
data-pdf-cancel>Cancel</button><button id="generate-pdf" class="primary-button"
|
||||||
type="submit">Generate PDF</button></div>
|
type="submit">Generate PDF</button></div>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function insertStandaloneLine(editor, content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function insertPageBreak(editor) {
|
export function insertPageBreak(editor) {
|
||||||
insertStandaloneLine(editor, "[PAGEBREAK]");
|
insertStandaloneLine(editor, "[PDF_PAGEBREAK]");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function insertHorizontalRule(editor) {
|
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 === "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 === "definition") toggleWrap(editor, "", `\n: ${t("editor.format.definition", {}, "Definition")}`, t("editor.format.term", {}, "Term"));
|
||||||
if (format === "page-break") insertPageBreak(editor);
|
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);
|
if (format === "horizontal-rule") insertHorizontalRule(editor);
|
||||||
editor.focus();
|
editor.focus();
|
||||||
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"
|
|||||||
|
|
||||||
function highlightMentions(html) {
|
function highlightMentions(html) {
|
||||||
if (!markdownMentionMatcher) return 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) => {
|
return html.split(/(<[^>]*>)/g).map(part => part.startsWith("<") ? part : part.replace(markdownMentionMatcher, (match, before, name) => {
|
||||||
const user = markdownMentionLookup.get(name.toLocaleLowerCase());
|
const user = markdownMentionLookup.get(name.toLocaleLowerCase());
|
||||||
return user ? `${before}<span class="markdown-mention" style="--owner:${user.color}" title="${escapeHtml(user.nickname)}">@${escapeHtml(user.nickname)}</span>` : match;
|
return user ? `${before}<span class="markdown-mention" style="--owner:${user.color}" title="${escapeHtml(user.nickname)}">@${escapeHtml(user.nickname)}</span>` : match;
|
||||||
@@ -632,10 +631,15 @@ export function renderMarkdown(source, lineOffset = 0, documentHeadings = null)
|
|||||||
html += `<dd data-source-line="${index + lineOffset + 1}">${inline(lines[index].replace(/^:\s+/, ""))}</dd>`;
|
html += `<dd data-source-line="${index + lineOffset + 1}">${inline(lines[index].replace(/^:\s+/, ""))}</dd>`;
|
||||||
}
|
}
|
||||||
html += `</dl>`;
|
html += `</dl>`;
|
||||||
} else if (/^\[PAGEBREAK\]$/i.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset).replace(' class="', ' class="page-break-marker ')} data-page-break="true">`;
|
} else if (/^\[(?:PDF_PAGEBREAK|PAGEBREAK)\]$/i.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset).replace(' class="', ' class="page-break-marker ')} data-page-break="true">`;
|
||||||
else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset)}>`;
|
else {
|
||||||
else if (line.trim()) html += `<p${attrs(index, true, "", "", lineOffset)}>${inline(line)}</p>`;
|
const pdfDirective = line.trim().match(/^\[PDF_(HEADER|FOOTER|ALIGN_TOP|ALIGN_CENTER)\]$/i);
|
||||||
else html += `<div${attrs(index, true, "", "", lineOffset)}><br></div>`;
|
if (pdfDirective) {
|
||||||
|
html += `<div class="pdf-layout-marker preview-source-line" data-pdf-directive="${pdfDirective[1].toLowerCase()}" data-source-line="${index + lineOffset + 1}" aria-hidden="true"></div>`;
|
||||||
|
} else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset)}>`;
|
||||||
|
else if (line.trim()) html += `<p${attrs(index, true, "", "", lineOffset)}>${inline(line)}</p>`;
|
||||||
|
else html += `<div${attrs(index, true, "", "", lineOffset)}><br></div>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export function createPadAdapter() {
|
|||||||
addressSelector: "#document-url",
|
addressSelector: "#document-url",
|
||||||
title: info => `${info.title} · RustPad`,
|
title: info => `${info.title} · RustPad`,
|
||||||
loadInfo: headers => api(base, { headers }),
|
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 }),
|
loadColor: headers => api(`${base}/editor-color`, { headers }),
|
||||||
saveColor: (headers, color) => api(`${base}/editor-color`, { method: "POST", headers, body: JSON.stringify({ color }) }),
|
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) }),
|
saveEditorSettings: (headers, settings) => api(`${base}/editor-settings`, { method: "POST", headers, body: JSON.stringify(settings) }),
|
||||||
@@ -74,6 +77,9 @@ export function createWorkspaceNoteAdapter() {
|
|||||||
addressSelector: "#document-url",
|
addressSelector: "#document-url",
|
||||||
title: info => `${info.title} · ${info.workspace_title}`,
|
title: info => `${info.title} · ${info.workspace_title}`,
|
||||||
loadInfo: headers => api(base, { headers }),
|
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 }),
|
loadColor: headers => api(`${base}/editor-color`, { headers }),
|
||||||
saveColor: (headers, color) => api(`${base}/editor-color`, { method: "POST", headers, body: JSON.stringify({ color }) }),
|
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) }),
|
saveEditorSettings: (headers, settings) => api(`${base}/editor-settings`, { method: "POST", headers, body: JSON.stringify(settings) }),
|
||||||
|
|||||||
+132
-6
@@ -37,7 +37,7 @@ import { syncSelectControl } from "@rustpad/select";
|
|||||||
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
|
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
|
||||||
|
|
||||||
export function startNoteEditor(adapter) {
|
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 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 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");
|
const saveState = document.querySelector("#save-state"), favoriteToggle = document.querySelector("#favorite-toggle");
|
||||||
@@ -309,13 +309,93 @@ export function startNoteEditor(adapter) {
|
|||||||
render();
|
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() {
|
async function loadNoteInfo() {
|
||||||
info = await adapter.loadInfo(sessionHeaders());
|
info = await adapter.loadInfo(sessionHeaders());
|
||||||
const documentTitle = document.querySelector("#document-title");
|
|
||||||
if (documentTitle && info.title) {
|
if (documentTitle && info.title) {
|
||||||
documentTitle.removeAttribute("data-i18n");
|
documentTitle.removeAttribute("data-i18n");
|
||||||
documentTitle.textContent = info.title;
|
documentTitle.textContent = info.title;
|
||||||
}
|
}
|
||||||
|
syncInlineTitleRename();
|
||||||
globalColor = info.global_color || ""; noteColor = info.note_color || "";
|
globalColor = info.global_color || ""; noteColor = info.note_color || "";
|
||||||
if (!getAuthToken()) noteColor = readGuestColor();
|
if (!getAuthToken()) noteColor = readGuestColor();
|
||||||
updateMarkdownFiles(info.files || []);
|
updateMarkdownFiles(info.files || []);
|
||||||
@@ -336,8 +416,7 @@ export function startNoteEditor(adapter) {
|
|||||||
syncMobileEditorControls();
|
syncMobileEditorControls();
|
||||||
updateCurrentUser(); return info;
|
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) {
|
function updatePresence(users) {
|
||||||
const entries = Array.isArray(users) ? users : [];
|
const entries = Array.isArray(users) ? users : [];
|
||||||
presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {});
|
presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {});
|
||||||
@@ -506,6 +585,7 @@ export function startNoteEditor(adapter) {
|
|||||||
const renderVersion = ++mermaidRenderVersion;
|
const renderVersion = ++mermaidRenderVersion;
|
||||||
const nodes = [...preview.querySelectorAll(".mermaid")];
|
const nodes = [...preview.querySelectorAll(".mermaid")];
|
||||||
if (!nodes.length) return;
|
if (!nodes.length) return;
|
||||||
|
preview.querySelectorAll(".mermaid-error").forEach(error => error.remove());
|
||||||
try {
|
try {
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
if (!node.dataset.mermaidSource) node.dataset.mermaidSource = node.textContent || "";
|
if (!node.dataset.mermaidSource) node.dataset.mermaidSource = node.textContent || "";
|
||||||
@@ -513,10 +593,12 @@ export function startNoteEditor(adapter) {
|
|||||||
const mermaid = await loadMermaid();
|
const mermaid = await loadMermaid();
|
||||||
mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" });
|
mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" });
|
||||||
await mermaid.run({ nodes });
|
await mermaid.run({ nodes });
|
||||||
|
if (renderVersion === mermaidRenderVersion) preview.querySelectorAll(".mermaid-error").forEach(error => error.remove());
|
||||||
} catch {
|
} catch {
|
||||||
if (renderVersion !== mermaidRenderVersion) return;
|
if (renderVersion !== mermaidRenderVersion) return;
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
if (!node.isConnected || !preview.contains(node) || !node.parentNode) return;
|
if (!node.isConnected || !preview.contains(node) || !node.parentNode) return;
|
||||||
|
if (node.previousElementSibling?.classList.contains("mermaid-error")) return;
|
||||||
const message = document.createElement("p");
|
const message = document.createElement("p");
|
||||||
message.className = "error mermaid-error";
|
message.className = "error mermaid-error";
|
||||||
message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid.");
|
message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid.");
|
||||||
@@ -552,8 +634,6 @@ export function startNoteEditor(adapter) {
|
|||||||
reconcileChildren(participantBadges, nodes);
|
reconcileChildren(participantBadges, nodes);
|
||||||
}
|
}
|
||||||
function syncOwnerLabels() {
|
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;
|
const canvas = ownerLabels.firstElementChild;
|
||||||
if (canvas?.classList.contains("owner-labels-canvas")) {
|
if (canvas?.classList.contains("owner-labels-canvas")) {
|
||||||
canvas.style.transform = `translate3d(0, ${-editor.scrollTop}px, 0)`;
|
canvas.style.transform = `translate3d(0, ${-editor.scrollTop}px, 0)`;
|
||||||
@@ -2268,6 +2348,7 @@ export function startNoteEditor(adapter) {
|
|||||||
const documentLinkCopy = document.querySelector("#document-link-copy");
|
const documentLinkCopy = document.querySelector("#document-link-copy");
|
||||||
documentLinkCopy?.addEventListener("click", copyCurrentLink);
|
documentLinkCopy?.addEventListener("click", copyCurrentLink);
|
||||||
documentLinkCopy?.addEventListener("keydown", event => {
|
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;
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
copyCurrentLink();
|
copyCurrentLink();
|
||||||
@@ -2306,6 +2387,51 @@ export function startNoteEditor(adapter) {
|
|||||||
renderFavoriteButton(false);
|
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;
|
let pendingPreviewFormatRange = null;
|
||||||
document.querySelectorAll("[data-format]").forEach(button => {
|
document.querySelectorAll("[data-format]").forEach(button => {
|
||||||
button.addEventListener("pointerdown", event => {
|
button.addEventListener("pointerdown", event => {
|
||||||
|
|||||||
+185
-48
@@ -128,6 +128,57 @@ function isBlankPrintLine(node) {
|
|||||||
return node.children.length === 1 && node.firstElementChild?.tagName === "BR";
|
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) {
|
function collapseHeadingGaps(root) {
|
||||||
root.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach(heading => {
|
root.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach(heading => {
|
||||||
let next = heading.nextElementSibling;
|
let next = heading.nextElementSibling;
|
||||||
@@ -274,34 +325,47 @@ function preparePrintPagination(root) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensurePrintableMermaid(root, darkMode = false) {
|
async function ensurePrintableMermaid(root, darkMode = false) {
|
||||||
const nodes = [...root.querySelectorAll(".mermaid[data-mermaid-source]")];
|
const nodes = [...root.querySelectorAll(".mermaid")];
|
||||||
if (!nodes.length) return;
|
if (!nodes.length) return;
|
||||||
|
|
||||||
|
root.querySelectorAll(".mermaid-error").forEach(error => error.remove());
|
||||||
|
|
||||||
const originals = nodes.map(node => ({
|
const originals = nodes.map(node => ({
|
||||||
node,
|
node,
|
||||||
html: node.innerHTML,
|
html: node.innerHTML,
|
||||||
processed: node.getAttribute("data-processed"),
|
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");
|
const staging = document.createElement("div");
|
||||||
staging.setAttribute("aria-hidden", "true");
|
staging.setAttribute("aria-hidden", "true");
|
||||||
staging.style.cssText = "position:fixed;left:-200vw;top:0;width:1200px;visibility:hidden;pointer-events:none;z-index:-1";
|
staging.style.cssText = "position:fixed;left:-200vw;top:0;width:1200px;visibility:hidden;pointer-events:none;z-index:-1";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mermaid = await loadMermaid();
|
const mermaid = await loadMermaid();
|
||||||
nodes.forEach(node => {
|
renderable.forEach(({ node, source }) => {
|
||||||
node.removeAttribute("data-processed");
|
node.removeAttribute("data-processed");
|
||||||
node.textContent = node.dataset.mermaidSource || "";
|
node.textContent = source;
|
||||||
});
|
});
|
||||||
|
|
||||||
staging.appendChild(root);
|
staging.appendChild(root);
|
||||||
document.body.appendChild(staging);
|
document.body.appendChild(staging);
|
||||||
mermaid.initialize({ startOnLoad: false, theme: darkMode ? "dark" : "default", securityLevel: "strict" });
|
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 {
|
} catch {
|
||||||
originals.forEach(({ node, html, processed }) => {
|
originals.forEach(({ node, html, processed, hadSvg }) => {
|
||||||
node.innerHTML = html;
|
node.innerHTML = html;
|
||||||
if (processed === null) node.removeAttribute("data-processed");
|
if (processed === null) node.removeAttribute("data-processed");
|
||||||
else node.setAttribute("data-processed", 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 {
|
} finally {
|
||||||
if (root.parentElement === staging) staging.removeChild(root);
|
if (root.parentElement === staging) staging.removeChild(root);
|
||||||
@@ -334,8 +398,10 @@ function measureLastPageFill(doc, documentRoot, pageContentHeight) {
|
|||||||
"z-index:-1",
|
"z-index:-1",
|
||||||
].join(";");
|
].join(";");
|
||||||
|
|
||||||
const title = documentRoot.querySelector(":scope > .pdf-document-title");
|
for (const child of documentRoot.children) {
|
||||||
if (title) probe.appendChild(title.cloneNode(true));
|
if (child === content) break;
|
||||||
|
if (child.matches?.(".pdf-document-title, .pdf-custom-header")) probe.appendChild(child.cloneNode(true));
|
||||||
|
}
|
||||||
|
|
||||||
const flow = content.cloneNode(true);
|
const flow = content.cloneNode(true);
|
||||||
flow.querySelectorAll(".pdf-page-break").forEach(pageBreak => {
|
flow.querySelectorAll(".pdf-page-break").forEach(pageBreak => {
|
||||||
@@ -367,40 +433,82 @@ function fallbackLastPageFill(documentRoot, spacer, pageContentHeight) {
|
|||||||
function alignFooterToLastPage(doc, pageHeightMm, marginMm) {
|
function alignFooterToLastPage(doc, pageHeightMm, marginMm) {
|
||||||
const documentRoot = doc.querySelector(".pdf-document");
|
const documentRoot = doc.querySelector(".pdf-document");
|
||||||
const spacer = doc.querySelector(".pdf-footer-spacer");
|
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;
|
if (!documentRoot || !spacer || !footer) return;
|
||||||
|
|
||||||
spacer.style.height = "0px";
|
spacer.style.height = "0px";
|
||||||
footer.style.removeProperty("margin-top");
|
footer.style.marginTop = "0px";
|
||||||
|
|
||||||
const pxPerMm = mmToPx(doc);
|
const pxPerMm = mmToPx(doc);
|
||||||
const pageContentHeight = (Number(pageHeightMm) - (2 * Number(marginMm))) * pxPerMm;
|
const pageContentHeight = (Number(pageHeightMm) - (2 * Number(marginMm))) * pxPerMm;
|
||||||
const footerHeight = footer.getBoundingClientRect().height;
|
const footerHeight = footer.getBoundingClientRect().height;
|
||||||
const footerMarginTop = Number.parseFloat(doc.defaultView?.getComputedStyle(footer).marginTop || "0") || 0;
|
const bottomGuard = Math.max(2, 1.5 * pxPerMm);
|
||||||
const bottomGuard = 0.75 * pxPerMm;
|
const minGap = 4 * pxPerMm;
|
||||||
if (!Number.isFinite(pageContentHeight) || pageContentHeight <= footerHeight || footerHeight <= 0) return;
|
if (!Number.isFinite(pageContentHeight) || pageContentHeight <= footerHeight || footerHeight <= 0) return;
|
||||||
|
|
||||||
const measuredFill = measureLastPageFill(doc, documentRoot, pageContentHeight);
|
const measuredFill = measureLastPageFill(doc, documentRoot, pageContentHeight);
|
||||||
const positionOnPage = measuredFill ?? fallbackLastPageFill(documentRoot, spacer, pageContentHeight);
|
const positionOnPage = measuredFill ?? fallbackLastPageFill(documentRoot, spacer, pageContentHeight);
|
||||||
const available = Math.max(0, pageContentHeight - positionOnPage);
|
const available = Math.max(0, pageContentHeight - positionOnPage);
|
||||||
|
|
||||||
let actualMarginTop = footerMarginTop;
|
const spacerHeight = available >= footerHeight + minGap + bottomGuard
|
||||||
let spacerHeight;
|
? 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`;
|
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) {
|
function preparePreview(preview) {
|
||||||
const clone = preview.cloneNode(true);
|
const clone = preview.cloneNode(true);
|
||||||
clone.removeAttribute("id");
|
clone.removeAttribute("id");
|
||||||
@@ -426,11 +534,16 @@ function preparePreview(preview) {
|
|||||||
|
|
||||||
if (clone.classList.contains("preview--raw")) {
|
if (clone.classList.contains("preview--raw")) {
|
||||||
clone.querySelectorAll(".preview-source-line").forEach(line => {
|
clone.querySelectorAll(".preview-source-line").forEach(line => {
|
||||||
if (!/^\[PAGEBREAK\]$/i.test(line.textContent.trim())) return;
|
const text = line.textContent.trim();
|
||||||
const pageBreak = document.createElement("div");
|
if (/^\[(?:PDF_PAGEBREAK|PAGEBREAK)\]$/i.test(text)) {
|
||||||
pageBreak.className = "pdf-page-break";
|
const pageBreak = document.createElement("div");
|
||||||
pageBreak.setAttribute("aria-hidden", "true");
|
pageBreak.className = "pdf-page-break";
|
||||||
line.replaceWith(pageBreak);
|
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);
|
marker.replaceWith(pageBreak);
|
||||||
});
|
});
|
||||||
|
|
||||||
preparePrintPagination(clone);
|
trimTrailingBlankPrintLines(clone);
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,8 +596,17 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) {
|
|||||||
background: ${pageBackground} !important;
|
background: ${pageBackground} !important;
|
||||||
color: ${textColor} !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-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 {
|
.pdf-footer-spacer {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -493,9 +615,9 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
.pdf-generated-footer {
|
.pdf-document-footer {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin-top: 8mm;
|
margin-top: 0;
|
||||||
padding-top: 1.5mm;
|
padding-top: 1.5mm;
|
||||||
border-top: .2mm solid ${borderColor};
|
border-top: .2mm solid ${borderColor};
|
||||||
color: ${footerColor};
|
color: ${footerColor};
|
||||||
@@ -505,6 +627,8 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) {
|
|||||||
break-inside: avoid-page;
|
break-inside: avoid-page;
|
||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
}
|
}
|
||||||
|
.pdf-custom-footer > :first-child { margin-top: 0 !important; }
|
||||||
|
.pdf-custom-footer > :last-child { margin-bottom: 0 !important; }
|
||||||
.pdf-content {
|
.pdf-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -521,20 +645,11 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) {
|
|||||||
.pdf-content a { color: inherit !important; text-decoration: underline; }
|
.pdf-content a { color: inherit !important; text-decoration: underline; }
|
||||||
|
|
||||||
/* Book-like pagination: keep small semantic units together, not whole sections. */
|
/* 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 h1,
|
||||||
.pdf-content h2,
|
.pdf-content h2,
|
||||||
.pdf-content h3 {
|
.pdf-content h3 {
|
||||||
break-after: auto;
|
break-after: auto;
|
||||||
page-break-after: auto;
|
page-break-after: auto;
|
||||||
padding-bottom: 1.15em;
|
|
||||||
margin-bottom: calc(.18em - 1.15em);
|
|
||||||
}
|
}
|
||||||
.pdf-content h4,
|
.pdf-content h4,
|
||||||
.pdf-content h5,
|
.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 > :first-child { margin-top: 0 !important; }
|
||||||
.pdf-content .pdf-keep-unit > :last-child { margin-bottom: 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 {
|
.pdf-content .pdf-keep-short {
|
||||||
break-inside: avoid-page !important;
|
break-inside: avoid-page !important;
|
||||||
page-break-inside: avoid !important;
|
page-break-inside: avoid !important;
|
||||||
}
|
}
|
||||||
.pdf-content .pdf-heading-lead > h1,
|
.pdf-content .pdf-heading-lead > h1,
|
||||||
.pdf-content .pdf-heading-lead > h2,
|
.pdf-content .pdf-heading-lead > h2,
|
||||||
.pdf-content .pdf-heading-lead > h3 {
|
.pdf-content .pdf-heading-lead > h3 { margin-bottom: .18em; }
|
||||||
padding-bottom: 0;
|
|
||||||
margin-bottom: .18em;
|
|
||||||
}
|
|
||||||
.pdf-content .pdf-atomic-visual {
|
.pdf-content .pdf-atomic-visual {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 100%;
|
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 format = PAGE_FORMATS[formatKey] || PAGE_FORMATS["a4-portrait"];
|
||||||
const safeMargin = [0, 8, 12, 20].includes(Number(marginMm)) ? Number(marginMm) : 12;
|
const safeMargin = [0, 8, 12, 20].includes(Number(marginMm)) ? Number(marginMm) : 12;
|
||||||
const printWindow = window.open("", "_blank");
|
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 */ }
|
try { printWindow.opener = null; } catch { /* same-origin hardening is best effort */ }
|
||||||
|
|
||||||
const content = preparePreview(preview);
|
const content = preparePreview(preview);
|
||||||
|
const pdfLayout = extractPdfLayout(content);
|
||||||
|
const resolvedVerticalAlign = pdfLayout.verticalAlign || (verticalAlign === "center" ? "center" : "top");
|
||||||
const titleBlock = includeTitle && title
|
const titleBlock = includeTitle && title
|
||||||
? `<header class="pdf-document-title"><h1>${escapeHtml(title)}</h1></header>`
|
? `<header class="pdf-document-title"><h1>${escapeHtml(title)}</h1></header>`
|
||||||
: "";
|
: "";
|
||||||
|
const customHeader = pdfLayout.headerHtml
|
||||||
|
? `<header class="pdf-custom-header">${pdfLayout.headerHtml}</header>`
|
||||||
|
: "";
|
||||||
const lang = escapeHtml(document.documentElement.lang || "en");
|
const lang = escapeHtml(document.documentElement.lang || "en");
|
||||||
const base = escapeHtml(`${location.origin}/`);
|
const base = escapeHtml(`${location.origin}/`);
|
||||||
const css = printStyles(format.cssSize, format.widthMm, format.heightMm, safeMargin, darkMode);
|
const css = printStyles(format.cssSize, format.widthMm, format.heightMm, safeMargin, darkMode);
|
||||||
const footerText = escapeHtml(t("editor.pdfGeneratedFooter", {}, "Generated by RustPad"));
|
const footerText = escapeHtml(t("editor.pdfGeneratedFooter", {}, "Generated by RustPad"));
|
||||||
|
const footerBlock = pdfLayout.footerHtml
|
||||||
|
? `<footer class="pdf-document-footer pdf-custom-footer">${pdfLayout.footerHtml}</footer>`
|
||||||
|
: `<footer class="pdf-document-footer pdf-generated-footer" aria-hidden="true">${footerText}</footer>`;
|
||||||
|
|
||||||
return (async () => {
|
return (async () => {
|
||||||
await ensurePrintableMermaid(content, darkMode);
|
await ensurePrintableMermaid(content, darkMode);
|
||||||
await ensureSyntaxHighlight(content);
|
await ensureSyntaxHighlight(content);
|
||||||
|
preparePrintPagination(content);
|
||||||
|
|
||||||
printWindow.document.open();
|
printWindow.document.open();
|
||||||
printWindow.document.write(`<!doctype html><html lang="${lang}" data-theme="${darkMode ? "dark" : "light"}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><base href="${base}"><title>${escapeHtml(title || "RustPad")}</title>${stylesheetLinks()}<style>${css}</style></head><body><main class="pdf-document">${titleBlock}${content.outerHTML}<div class="pdf-footer-spacer" aria-hidden="true"></div><footer class="pdf-generated-footer" aria-hidden="true">${footerText}</footer></main></body></html>`);
|
printWindow.document.write(`<!doctype html><html lang="${lang}" data-theme="${darkMode ? "dark" : "light"}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><base href="${base}"><title>${escapeHtml(title || "RustPad")}</title>${stylesheetLinks()}<style>${css}</style></head><body><main class="pdf-document">${customHeader}${titleBlock}${content.outerHTML}<div class="pdf-footer-spacer" aria-hidden="true"></div>${footerBlock}</main></body></html>`);
|
||||||
printWindow.document.close();
|
printWindow.document.close();
|
||||||
|
|
||||||
const runPrint = async () => {
|
const runPrint = async () => {
|
||||||
await waitForPrintableLayout(printWindow.document);
|
await waitForPrintableLayout(printWindow.document);
|
||||||
|
fitOversizedKeepUnits(printWindow.document, format.heightMm, safeMargin);
|
||||||
|
alignContentVertically(printWindow.document, format.heightMm, safeMargin, resolvedVerticalAlign);
|
||||||
alignFooterToLastPage(printWindow.document, format.heightMm, safeMargin);
|
alignFooterToLastPage(printWindow.document, format.heightMm, safeMargin);
|
||||||
await nextFrame(printWindow);
|
await nextFrame(printWindow);
|
||||||
|
fitOversizedKeepUnits(printWindow.document, format.heightMm, safeMargin);
|
||||||
|
alignContentVertically(printWindow.document, format.heightMm, safeMargin, resolvedVerticalAlign);
|
||||||
alignFooterToLastPage(printWindow.document, format.heightMm, safeMargin);
|
alignFooterToLastPage(printWindow.document, format.heightMm, safeMargin);
|
||||||
await nextFrame(printWindow);
|
await nextFrame(printWindow);
|
||||||
printWindow.focus();
|
printWindow.focus();
|
||||||
@@ -868,6 +998,7 @@ export function bindPdfExport({ button, dialog, form, preview, title }) {
|
|||||||
if (!button || !dialog || !form || !preview) return;
|
if (!button || !dialog || !form || !preview) return;
|
||||||
const formatSelect = form.querySelector("#pdf-page-format");
|
const formatSelect = form.querySelector("#pdf-page-format");
|
||||||
const marginSelect = form.querySelector("#pdf-page-margin");
|
const marginSelect = form.querySelector("#pdf-page-margin");
|
||||||
|
const verticalAlignSelect = form.querySelector("#pdf-vertical-align");
|
||||||
const darkMode = form.querySelector("#pdf-dark-mode");
|
const darkMode = form.querySelector("#pdf-dark-mode");
|
||||||
const includeTitle = form.querySelector("#pdf-include-title");
|
const includeTitle = form.querySelector("#pdf-include-title");
|
||||||
const cancelButtons = form.querySelectorAll("[data-pdf-cancel]");
|
const cancelButtons = form.querySelectorAll("[data-pdf-cancel]");
|
||||||
@@ -878,6 +1009,10 @@ export function bindPdfExport({ button, dialog, form, preview, title }) {
|
|||||||
const savedMargin = saved("margin", "12");
|
const savedMargin = saved("margin", "12");
|
||||||
marginSelect.value = ["0", "8", "12", "20"].includes(savedMargin) ? savedMargin : "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";
|
if (darkMode) darkMode.checked = saved("dark-mode", "on") !== "off";
|
||||||
includeTitle.checked = saved("include-title", "off") === "on";
|
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);
|
const marginMm = Number(marginSelect?.value ?? 12);
|
||||||
save("format", formatKey);
|
save("format", formatKey);
|
||||||
save("margin", marginMm);
|
save("margin", marginMm);
|
||||||
|
save("vertical-align", verticalAlignSelect?.value === "center" ? "center" : "top");
|
||||||
save("dark-mode", darkMode?.checked === false ? "off" : "on");
|
save("dark-mode", darkMode?.checked === false ? "off" : "on");
|
||||||
save("include-title", includeTitle.checked ? "on" : "off");
|
save("include-title", includeTitle.checked ? "on" : "off");
|
||||||
try {
|
try {
|
||||||
@@ -901,6 +1037,7 @@ export function bindPdfExport({ button, dialog, form, preview, title }) {
|
|||||||
marginMm,
|
marginMm,
|
||||||
includeTitle: includeTitle.checked,
|
includeTitle: includeTitle.checked,
|
||||||
darkMode: darkMode?.checked !== false,
|
darkMode: darkMode?.checked !== false,
|
||||||
|
verticalAlign: verticalAlignSelect?.value === "center" ? "center" : "top",
|
||||||
});
|
});
|
||||||
dialog.close("confirm");
|
dialog.close("confirm");
|
||||||
void printJob.catch(error => {
|
void printJob.catch(error => {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ async function renderMermaid() {
|
|||||||
const renderVersion = ++mermaidRenderVersion;
|
const renderVersion = ++mermaidRenderVersion;
|
||||||
const nodes = [...content.querySelectorAll(".mermaid")];
|
const nodes = [...content.querySelectorAll(".mermaid")];
|
||||||
if (!nodes.length) return;
|
if (!nodes.length) return;
|
||||||
|
content.querySelectorAll(".mermaid-error").forEach(error => error.remove());
|
||||||
try {
|
try {
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
if (!node.dataset.mermaidSource) node.dataset.mermaidSource = node.textContent || "";
|
if (!node.dataset.mermaidSource) node.dataset.mermaidSource = node.textContent || "";
|
||||||
@@ -44,10 +45,12 @@ async function renderMermaid() {
|
|||||||
const mermaid = await loadMermaid();
|
const mermaid = await loadMermaid();
|
||||||
mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" });
|
mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" });
|
||||||
await mermaid.run({ nodes });
|
await mermaid.run({ nodes });
|
||||||
|
if (renderVersion === mermaidRenderVersion) content.querySelectorAll(".mermaid-error").forEach(error => error.remove());
|
||||||
} catch {
|
} catch {
|
||||||
if (renderVersion !== mermaidRenderVersion) return;
|
if (renderVersion !== mermaidRenderVersion) return;
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
if (!node.isConnected || !content.contains(node) || !node.parentNode) return;
|
if (!node.isConnected || !content.contains(node) || !node.parentNode) return;
|
||||||
|
if (node.previousElementSibling?.classList.contains("mermaid-error")) return;
|
||||||
const message = document.createElement("p");
|
const message = document.createElement("p");
|
||||||
message.className = "error mermaid-error";
|
message.className = "error mermaid-error";
|
||||||
message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid.");
|
message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid.");
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ function localAsset(path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function once(key, factory) {
|
function once(key, factory) {
|
||||||
if (!promiseCache.has(key)) promiseCache.set(key, Promise.resolve().then(factory));
|
if (promiseCache.has(key)) return promiseCache.get(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) {
|
function loadClassicScript(path, globalName) {
|
||||||
|
|||||||
+97
-4
@@ -58,6 +58,15 @@ const notesViewKey = `rustpad:workspace:${slug}:notes-view`;
|
|||||||
let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid";
|
let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid";
|
||||||
let notesCache = [];
|
let notesCache = [];
|
||||||
let archivedNotesCache = [];
|
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() {
|
function updateWorkspacePasswordControl() {
|
||||||
workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password);
|
workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password);
|
||||||
@@ -266,8 +275,6 @@ async function toggleNoteFavorite(button) {
|
|||||||
{ title: t("favorites.title", {}, "Favorites") },
|
{ title: t("favorites.title", {}, "Favorites") },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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) {
|
if (error.status === 403) {
|
||||||
try {
|
try {
|
||||||
const session = await validateCurrentSession();
|
const session = await validateCurrentSession();
|
||||||
@@ -296,7 +303,7 @@ function renderNotes(notes = notesCache) {
|
|||||||
notesList.innerHTML = `<div class="notes-table-scroll"><table><thead><tr>${favoriteHeader}<th>Name</th><th>Created by</th><th>Participants</th><th>Files</th><th>Revisions</th><th>Status</th><th>Updated</th><th class="notes-table-actions">Actions</th></tr></thead><tbody>${notes.map(note => `
|
notesList.innerHTML = `<div class="notes-table-scroll"><table><thead><tr>${favoriteHeader}<th>Name</th><th>Created by</th><th>Participants</th><th>Files</th><th>Revisions</th><th>Status</th><th>Updated</th><th class="notes-table-actions">Actions</th></tr></thead><tbody>${notes.map(note => `
|
||||||
<tr>
|
<tr>
|
||||||
${accountSession ? `<td class="notes-table-favorite">${favoriteButton(note, true)}</td>` : ""}
|
${accountSession ? `<td class="notes-table-favorite">${favoriteButton(note, true)}</td>` : ""}
|
||||||
<td><a class="note-table-link" href="${escapeHtml(safeAppUrl(note.url))}">${escapeHtml(note.title)}</a></td>
|
<td><a class="note-table-link" href="${escapeHtml(safeAppUrl(note.url))}"><span class="note-list-inline-title${note.can_rename ? " note-list-inline-title--renamable" : ""}" ${note.can_rename ? `data-note-rename="${escapeHtml(note.slug)}" title="${escapeHtml(t("workspace.renameHint", {}, "Double-click to rename"))}"` : ""}>${escapeHtml(note.title)}</span></a></td>
|
||||||
<td class="note-author">${escapeHtml(note.created_by || "Unknown")}</td>
|
<td class="note-author">${escapeHtml(note.created_by || "Unknown")}</td>
|
||||||
<td>${Number(note.participant_count) || 0}</td>
|
<td>${Number(note.participant_count) || 0}</td>
|
||||||
<td>${Number(note.file_count) || 0} <span class="note-status">(${formatBytes(note.file_size_bytes)})</span></td>
|
<td>${Number(note.file_count) || 0} <span class="note-status">(${formatBytes(note.file_size_bytes)})</span></td>
|
||||||
@@ -310,13 +317,70 @@ function renderNotes(notes = notesCache) {
|
|||||||
notesList.innerHTML = notes.map(note => `
|
notesList.innerHTML = notes.map(note => `
|
||||||
<article class="note-card-wrap${accountSession ? " note-card-wrap--favorite-enabled" : ""}">
|
<article class="note-card-wrap${accountSession ? " note-card-wrap--favorite-enabled" : ""}">
|
||||||
<a class="note-card" href="${escapeHtml(safeAppUrl(note.url))}">
|
<a class="note-card" href="${escapeHtml(safeAppUrl(note.url))}">
|
||||||
<div class="note-card-title"><h3>${escapeHtml(note.title)}</h3>${note.protected ? '<span class="protect-badge">Protected</span>' : ''}</div>
|
<div class="note-card-title"><h3><span class="note-list-inline-title${note.can_rename ? " note-list-inline-title--renamable" : ""}" ${note.can_rename ? `data-note-rename="${escapeHtml(note.slug)}" title="${escapeHtml(t("workspace.renameHint", {}, "Double-click to rename"))}"` : ""}>${escapeHtml(note.title)}</span></h3>${note.protected ? '<span class="protect-badge">Protected</span>' : ''}</div>
|
||||||
<div class="note-card-meta"><span>Created by: ${escapeHtml(note.created_by || "Unknown")}</span>${noteStats(note)}<span>Updated: ${formatDate(note.updated_at)}</span></div>
|
<div class="note-card-meta"><span>Created by: ${escapeHtml(note.created_by || "Unknown")}</span>${noteStats(note)}<span>Updated: ${formatDate(note.updated_at)}</span></div>
|
||||||
</a>
|
</a>
|
||||||
${accountSession ? `<div class="note-card-favorite">${favoriteButton(note)}</div>` : ""}
|
${accountSession ? `<div class="note-card-favorite">${favoriteButton(note)}</div>` : ""}
|
||||||
${deleteButton(note)}
|
${deleteButton(note)}
|
||||||
</article>`).join("");
|
</article>`).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() {
|
async function showSystemNotFound() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${location.pathname.replace(/\/$/, "")}/__not_found__`, {
|
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" }); }
|
} catch (err) { error.textContent = err.message; toast.danger(err.message, { title: "Could not create note" }); }
|
||||||
});
|
});
|
||||||
notesList.addEventListener("click", async event => {
|
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]");
|
const favorite = event.target.closest("[data-favorite-note]");
|
||||||
if (favorite) {
|
if (favorite) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
Reference in new issue
Block a user