diff --git a/.gitignore b/.gitignore index 2befdee..423b229 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +__pycache__ /target rustpad.db rustpad.db-shm @@ -8,4 +9,4 @@ rustpad.db-wal data/db/.db* data/db/*/* data/files/* -*.zip \ No newline at end of file +*.zip diff --git a/__pycache__/make_zip.cpython-313.pyc b/__pycache__/make_zip.cpython-313.pyc deleted file mode 100644 index 278d7b6..0000000 Binary files a/__pycache__/make_zip.cpython-313.pyc and /dev/null differ diff --git a/dev.sh b/dev.sh old mode 100755 new mode 100644 diff --git a/migrations/mysql/0005_public_task_updates.sql b/migrations/mysql/0005_public_task_updates.sql new file mode 100644 index 0000000..eb332ed --- /dev/null +++ b/migrations/mysql/0005_public_task_updates.sql @@ -0,0 +1 @@ +ALTER TABLE published_pages ADD COLUMN allow_task_updates BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/postgres/0005_public_task_updates.sql b/migrations/postgres/0005_public_task_updates.sql new file mode 100644 index 0000000..eb332ed --- /dev/null +++ b/migrations/postgres/0005_public_task_updates.sql @@ -0,0 +1 @@ +ALTER TABLE published_pages ADD COLUMN allow_task_updates BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/sqlite/0005_public_task_updates.sql b/migrations/sqlite/0005_public_task_updates.sql new file mode 100644 index 0000000..63c3e8e --- /dev/null +++ b/migrations/sqlite/0005_public_task_updates.sql @@ -0,0 +1 @@ +ALTER TABLE published_pages ADD COLUMN allow_task_updates INTEGER NOT NULL DEFAULT 0; diff --git a/src/api.rs b/src/api.rs index 9d68bdb..d597d4f 100644 --- a/src/api.rs +++ b/src/api.rs @@ -27,6 +27,7 @@ pub struct PublicPageResponse { title: String, content: String, updated_at: String, + allow_task_updates: bool, } #[derive(Debug, Deserialize)] @@ -48,6 +49,20 @@ pub struct PasswordRequest { password: Option, } +#[derive(Debug, Deserialize)] +pub struct PublishRequest { + #[serde(default)] + password: Option, + #[serde(default)] + allow_task_updates: bool, +} + +#[derive(Debug, Deserialize)] +pub struct PublicTaskUpdateRequest { + source_line: usize, + checked: bool, +} + #[derive(Debug, Deserialize)] pub struct CreateNoteRequest { name: String, @@ -100,6 +115,7 @@ pub struct NoteInfo { title: String, protected: bool, note_protected: bool, + allow_public_task_updates: bool, created_at: String, updated_at: String, } @@ -206,6 +222,7 @@ pub async fn note_info( title: note.title, protected: workspace.password_hash.is_some(), note_protected: note.protected, + allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?, created_at: db::normalize_timestamp(¬e.created_at), updated_at: db::normalize_timestamp(¬e.updated_at), })) @@ -374,6 +391,7 @@ pub struct PadInfo { slug: String, title: String, protected: bool, + allow_public_task_updates: bool, created_at: String, updated_at: String, } @@ -410,6 +428,7 @@ pub async fn pad_info( slug: pad.slug, title: pad.title, protected: pad.password_hash.is_some(), + allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?, created_at: db::normalize_timestamp(&pad.created_at), updated_at: db::normalize_timestamp(&pad.updated_at), })) @@ -418,20 +437,22 @@ pub async fn pad_info( pub async fn publish_pad_page( State(state): State, Path(slug): Path, - Json(payload): Json, + Json(payload): Json, ) -> Result, ApiError> { let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?; let token = db::publish_pad(&state.db, pad.id).await?; + db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).await?; Ok(Json(PublishResponse { url: format!("/s/{token}") })) } pub async fn publish_note_page( State(state): State, Path((workspace_slug, note_slug)): Path<(String, String)>, - Json(payload): Json, + Json(payload): Json, ) -> Result, ApiError> { let (_, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?; let token = db::publish_note(&state.db, note.id).await?; + db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).await?; Ok(Json(PublishResponse { url: format!("/s/{token}") })) } @@ -446,6 +467,23 @@ pub async fn public_page( title: page.title, content: page.content, updated_at: db::normalize_timestamp(&page.updated_at), + allow_task_updates: page.allow_task_updates, + })) +} + +pub async fn update_public_task( + State(state): State, + Path(token): Path, + Json(payload): Json, +) -> Result, ApiError> { + let current = db::find_published_page(&state.db, &token).await?.ok_or_else(ApiError::not_found_note)?; + if !current.allow_task_updates { return Err(ApiError::forbidden("Task updates are disabled for this page")); } + let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked).await?.ok_or_else(ApiError::not_found_note)?; + Ok(Json(PublicPageResponse { + title: page.title, + content: page.content, + updated_at: db::normalize_timestamp(&page.updated_at), + allow_task_updates: page.allow_task_updates, })) } @@ -767,6 +805,9 @@ impl ApiError { message: "Invalid password".into(), } } + fn forbidden(message: &str) -> Self { + Self { status: StatusCode::FORBIDDEN, message: message.into() } + } fn not_found_workspace() -> Self { Self { status: StatusCode::NOT_FOUND, diff --git a/src/app.rs b/src/app.rs index fb3c291..75d7c8f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -20,6 +20,7 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize .route("/f/{token}/{filename}", get(api::download_file)) .route("/files/{directory}/{filename}", get(api::download_legacy_file)) .route("/api/public/{token}", get(api::public_page)) + .route("/api/public/{token}/tasks", post(api::update_public_task)) .route("/api/pads", post(api::create_pad)) .route("/api/pads/{slug}", get(api::pad_info)) .route("/api/pads/{slug}/history", post(api::pad_history)) diff --git a/src/db.rs b/src/db.rs index dcec1a9..949ce2a 100644 --- a/src/db.rs +++ b/src/db.rs @@ -382,14 +382,42 @@ pub async fn list_pad_revisions( .await } -#[derive(Debug, Clone, Serialize, FromRow)] +#[derive(Debug, Clone, Serialize)] pub struct PublishedPage { pub token: String, + pub pad_id: Option, + pub note_id: Option, + pub allow_task_updates: bool, pub title: String, pub content: String, pub updated_at: String, } +#[derive(Debug, Clone, FromRow)] +struct PublishedPageRow { + token: String, + pad_id: Option, + note_id: Option, + allow_task_updates: i64, + title: String, + content: String, + updated_at: String, +} + +impl From for PublishedPage { + fn from(value: PublishedPageRow) -> Self { + Self { + token: value.token, + pad_id: value.pad_id, + note_id: value.note_id, + allow_task_updates: value.allow_task_updates != 0, + title: value.title, + content: value.content, + updated_at: value.updated_at, + } + } +} + pub async fn publish_pad(pool: &Database, pad_id: i64) -> Result { if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q017)) .bind(pad_id) @@ -425,10 +453,63 @@ pub async fn publish_note(pool: &Database, note_id: i64) -> Result Result, sqlx::Error> { - sqlx::query_as::<_, PublishedPage>(queries::get(pool.kind(), queries::Q021)) - .bind(token) - .fetch_optional(pool.pool()) - .await + Ok(sqlx::query_as::<_, PublishedPageRow>(queries::get(pool.kind(), queries::Q021)) + .bind(token) + .fetch_optional(pool.pool()) + .await? + .map(Into::into)) +} + +pub async fn pad_public_task_updates(pool: &Database, pad_id: i64) -> Result { + let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q044)) + .bind(pad_id) + .fetch_optional(pool.pool()) + .await? + .unwrap_or(0); + Ok(value != 0) +} + +pub async fn note_public_task_updates(pool: &Database, note_id: i64) -> Result { + let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q045)) + .bind(note_id) + .fetch_optional(pool.pool()) + .await? + .unwrap_or(0); + Ok(value != 0) +} + +pub async fn set_pad_public_task_updates(pool: &Database, pad_id: i64, allow: bool) -> Result<(), sqlx::Error> { + publish_pad(pool, pad_id).await?; + sqlx::query(queries::get(pool.kind(), queries::Q040)).bind(if allow { 1i64 } else { 0i64 }).bind(pad_id).execute(pool.pool()).await?; + Ok(()) +} + +pub async fn set_note_public_task_updates(pool: &Database, note_id: i64, allow: bool) -> Result<(), sqlx::Error> { + publish_note(pool, note_id).await?; + sqlx::query(queries::get(pool.kind(), queries::Q041)).bind(if allow { 1i64 } else { 0i64 }).bind(note_id).execute(pool.pool()).await?; + Ok(()) +} + +pub async fn update_public_task(pool: &Database, token: &str, source_line: usize, checked: bool) -> Result, sqlx::Error> { + let Some(mut page) = find_published_page(pool, token).await? else { return Ok(None); }; + if !page.allow_task_updates || source_line == 0 { return Ok(Some(page)); } + let mut lines: Vec = page.content.split('\n').map(str::to_owned).collect(); + let Some(line) = lines.get_mut(source_line - 1) else { return Ok(Some(page)); }; + let bytes = line.as_bytes(); + let mut i = 0usize; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { i += 1; } + if i >= bytes.len() || !matches!(bytes[i], b'-' | b'*' | b'+') { return Ok(Some(page)); } + i += 1; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { i += 1; } + if i + 2 >= bytes.len() || bytes[i] != b'[' || !matches!(bytes[i + 1], b' ' | b'x' | b'X') || bytes[i + 2] != b']' { return Ok(Some(page)); } + line.replace_range(i + 1..i + 2, if checked { "x" } else { " " }); + page.content = lines.join("\n"); + if let Some(id) = page.pad_id { + sqlx::query(queries::get(pool.kind(), queries::Q042)).bind(&page.content).bind(id).execute(pool.pool()).await?; + } else if let Some(id) = page.note_id { + sqlx::query(queries::get(pool.kind(), queries::Q043)).bind(&page.content).bind(id).execute(pool.pool()).await?; + } + find_published_page(pool, token).await } pub async fn pad_file_token(pool: &Database, pad_id: i64) -> Result { diff --git a/src/queries.rs b/src/queries.rs index 952400f..bea45d7 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -21,7 +21,7 @@ pub const Q017: &str = "SELECT token FROM published_pages WHERE pad_id = ?"; pub const Q018: &str = "INSERT INTO published_pages (token, pad_id) VALUES (?, ?)"; pub const Q019: &str = "SELECT token FROM published_pages WHERE note_id = ?"; pub const Q020: &str = "INSERT INTO published_pages (token, note_id) VALUES (?, ?)"; -pub const Q021: &str = "SELECT pp.token, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = ?"; +pub const Q021: &str = "SELECT pp.token, pp.pad_id, pp.note_id, CASE WHEN pp.allow_task_updates THEN 1 ELSE 0 END AS allow_task_updates, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = ?"; pub const Q022: &str = "SELECT file_token FROM pads WHERE id = ?"; pub const Q023: &str = "UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL"; pub const Q024: &str = "SELECT file_token FROM notes WHERE id = ?"; @@ -65,3 +65,11 @@ pub fn get(kind: DatabaseKind, query: &'static str) -> &'static str { cache.insert(cache_key, converted); converted } + +pub const Q040: &str = "UPDATE published_pages SET allow_task_updates = ? WHERE pad_id = ?"; +pub const Q041: &str = "UPDATE published_pages SET allow_task_updates = ? WHERE note_id = ?"; +pub const Q042: &str = "UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; +pub const Q043: &str = "UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; + +pub const Q044: &str = "SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"; +pub const Q045: &str = "SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"; diff --git a/static/css/styles.css b/static/css/styles.css index fd5c1c6..f2c5aa6 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -398,3 +398,74 @@ dialog::backdrop { background: rgba(4,6,9,.82); } .note-delete-button:disabled { cursor: not-allowed; color: var(--muted-2); opacity: .55; } .notes-view-switch button { cursor: pointer; } .notes-view-switch button.active { cursor: default; } + +/* Preview editing and source line numbers. */ +.preview { padding: 12px 24px 12px 62px; line-height: 1.32; } +.preview-source-line { position: relative; min-height: 1.32em; } +.preview-source-line::before { content: attr(data-source-line); position: absolute; right: calc(100% + 18px); width: 32px; color: #596270; text-align: right; font: 400 .72rem/1.32 ui-monospace, SFMono-Regular, Consolas, monospace; user-select: none; } +.preview-editable { border-radius: 4px; outline: none; cursor: text; } +.preview-editable:hover { background: rgba(255,255,255,.025); } +.preview-editable:focus { background: rgba(124,104,238,.08); box-shadow: 0 0 0 1px rgba(124,104,238,.25); } +.markdown-body p { margin: .18em 0; } +.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin: .48em 0 .18em; } +.markdown-body blockquote { margin: .3em 0; } +.markdown-body ul, .markdown-body ol { margin: .22em 0; } +.compact-editor .preview { line-height: 1.24; } +.hide-line-numbers .preview { padding-left: 24px; } +.hide-line-numbers .preview-source-line::before { display: none; } + + +/* Extended Markdown */ +.markdown-body .table-wrap { overflow-x: auto; margin: .7em 0; } +.markdown-body table { width: 100%; border-collapse: collapse; } +.markdown-body th, .markdown-body td { padding: .55em .7em; border: 1px solid var(--border); vertical-align: top; } +.markdown-body th { background: var(--surface-2); color: var(--text); } +.markdown-body mark { padding: .05em .18em; border-radius: 3px; background: #6d5b16; color: #fff2a8; } +.markdown-body sub, .markdown-body sup { line-height: 0; } +.markdown-body dl { margin: .7em 0; } +.markdown-body dt { font-weight: 750; } +.markdown-body dd { margin: .25em 0 .65em 1.5em; color: #b9c2cf; } + +.markdown-body .task-list{margin:0;padding:0;list-style:none} +.markdown-body .task-list-item{position:relative;display:grid;grid-template-columns:1em minmax(0,1fr);grid-template-rows:1.32em;column-gap:.45em;align-items:center;min-height:1.32em;margin:0;padding:0;line-height:1.32} +.markdown-body .task-list-item::before{position:absolute;top:0;right:calc(100% + 18px);width:32px;height:1.32em;line-height:1.32em;transform:none} +.markdown-body .task-checkbox{grid-column:1;grid-row:1;width:1em;height:1em;margin:0;align-self:center;accent-color:var(--accent);cursor:pointer} +.markdown-body .task-list-item>span{grid-column:2;grid-row:1;display:block;min-width:0;margin:0;padding:0;line-height:1.32} +.hide-line-numbers .markdown-body .task-list-item::before{display:none} +.compact-editor .markdown-body .task-list-item{grid-template-rows:1.24em;min-height:1.24em;line-height:1.24} +.compact-editor .markdown-body .task-list-item::before,.compact-editor .markdown-body .task-list-item>span{height:1.24em;line-height:1.24} + +.markdown-body .task-list-item>span{min-width:0} +.markdown-body .footnotes { margin-top: 1.5em; color: var(--muted); font-size: .88em; } +.markdown-body .footnotes ol { padding-left: 1.5em; } +.markdown-body .footnote-backref { text-decoration: none; } +.shortcuts-panel { width: 100%; } +.shortcut-grid { display: grid; grid-template-columns: max-content 1fr; gap: 10px 18px; align-items: center; } +.shortcut-grid kbd { padding: 5px 8px; border: 1px solid var(--border-strong); border-bottom-width: 2px; border-radius: 6px; background: #0d1015; color: #e6eaf0; font: 600 .76rem/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; } +.shortcut-grid span { color: #c3cad4; font-size: .82rem; } +@media (max-width: 520px) { + .shortcut-grid { grid-template-columns: 1fr; gap: 5px; } + .shortcut-grid span { margin-bottom: 7px; } +} + +.footer-left, .footer-right { display:flex; align-items:center; gap:6px; min-width:0; } +.footer-status.status { min-height:0; padding:0; font-size:inherit; } +.markdown-more { position:relative; } +.markdown-more > summary { display:flex; align-items:center; min-height:34px; padding:0 9px; border:1px solid transparent; border-radius:7px; color:#b8c0cc; font-size:.78rem; list-style:none; cursor:pointer; } +.markdown-more > summary::-webkit-details-marker { display:none; } +.markdown-more > summary:hover, .markdown-more[open] > summary { border-color:var(--border); background:var(--surface-2); color:white; } +.markdown-more-menu { position:absolute; top:calc(100% + 8px); left:0; z-index:30; display:grid; grid-template-columns:repeat(2,minmax(120px,1fr)); gap:4px; width:290px; padding:8px; border:1px solid var(--border-strong); border-radius:9px; background:#11151c; box-shadow:0 14px 40px rgba(0,0,0,.4); } +.editor-toolbar .markdown-more-menu button { justify-content:flex-start; text-align:left; } +.hljs { color:#d7dae0; } +.hljs-keyword,.hljs-selector-tag,.hljs-literal { color:#c792ea; } +.hljs-string,.hljs-attr { color:#c3e88d; } +.hljs-number,.hljs-symbol { color:#f78c6c; } +.hljs-comment { color:#697383; font-style:italic; } +.public-content .task-checkbox { pointer-events:auto; } +@media (max-width:720px){.footer-left,.footer-right{flex-wrap:wrap}.markdown-more-menu{position:fixed;left:12px;right:12px;top:auto;bottom:58px;width:auto;}} +.public-task-toggle { display:inline-flex; align-items:center; gap:7px; color:var(--muted); font-size:.76rem; white-space:nowrap; } +.public-task-toggle input { width:15px; min-height:15px; height:15px; margin:0; accent-color:var(--accent); } +.public-page .preview-source-line { cursor:default; } +.public-page .task-checkbox:not(:disabled) { cursor:pointer; } +.public-page .task-checkbox:disabled { cursor:not-allowed; opacity:.55; } + diff --git a/static/js/editor-format.js b/static/js/editor-format.js index 24cc039..a2ff0a6 100644 --- a/static/js/editor-format.js +++ b/static/js/editor-format.js @@ -1,26 +1,77 @@ +function selection(editor) { + return { start: editor.selectionStart, end: editor.selectionEnd }; +} + +function toggleWrap(editor, before, after = before, placeholder = "tekst") { + let { start, end } = selection(editor); + const value = editor.value; + const selected = value.slice(start, end); + + if (start >= before.length && value.slice(start - before.length, start) === before && value.slice(end, end + after.length) === after) { + editor.setRangeText(selected, start - before.length, end + after.length, "select"); + return; + } + if (selected.startsWith(before) && selected.endsWith(after) && selected.length >= before.length + after.length) { + editor.setRangeText(selected.slice(before.length, -after.length), start, end, "select"); + return; + } + + const text = selected || placeholder; + editor.setRangeText(before + text + after, start, end, "select"); + if (!selected) editor.setSelectionRange(start + before.length, start + before.length + text.length); +} + +function togglePrefix(editor, prefixFactory) { + const { start, end } = selection(editor); + const lineStart = editor.value.lastIndexOf("\n", start - 1) + 1; + const selected = editor.value.slice(lineStart, end); + const lines = selected.split("\n"); + const prefixes = lines.map((_, index) => typeof prefixFactory === "function" ? prefixFactory(index) : prefixFactory); + const allPrefixed = lines.every((line, index) => line.startsWith(prefixes[index])); + const replacement = lines.map((line, index) => allPrefixed ? line.slice(prefixes[index].length) : prefixes[index] + line).join("\n"); + editor.setRangeText(replacement, lineStart, end, "select"); +} + export function applyFormat(editor, format) { - const wrap = (before, after = before, placeholder = "tekst") => { - const start = editor.selectionStart, end = editor.selectionEnd; - const selected = editor.value.slice(start, end) || placeholder; - editor.setRangeText(before + selected + after, start, end, "select"); - }; - const prefix = (value) => { - const start = editor.selectionStart, end = editor.selectionEnd; - const lineStart = editor.value.lastIndexOf("\n", start - 1) + 1; - const selected = editor.value.slice(lineStart, end); - editor.setRangeText(selected.split("\n").map((line, index) => typeof value === "function" ? value(index) + line : value + line).join("\n"), lineStart, end, "select"); - }; - if (format === "bold") wrap("**"); - if (format === "italic") wrap("*"); - if (format === "strike") wrap("~~"); - if (format === "heading" || format === "heading2") prefix("## "); - if (format === "heading1") prefix("# "); - if (format === "heading3") prefix("### "); - if (format === "heading4") prefix("#### "); - if (format === "bullet") prefix("- "); - if (format === "number") prefix((index) => `${index + 1}. `); - if (format === "quote") prefix("> "); - if (format === "link") wrap("[", "](https://)", "description"); + if (format === "bold") toggleWrap(editor, "**"); + if (format === "italic") toggleWrap(editor, "*"); + if (format === "strike") toggleWrap(editor, "~~"); + if (format === "heading" || format === "heading2") togglePrefix(editor, "## "); + if (format === "heading1") togglePrefix(editor, "# "); + if (format === "heading3") togglePrefix(editor, "### "); + if (format === "heading4") togglePrefix(editor, "#### "); + if (format === "bullet") togglePrefix(editor, "- "); + if (format === "number") togglePrefix(editor, index => `${index + 1}. `); + if (format === "task") togglePrefix(editor, "- [ ] "); + if (format === "quote") togglePrefix(editor, "> "); + if (format === "link") toggleWrap(editor, "[", "](https://)", "description"); + if (format === "inline-code") toggleWrap(editor, "`", "`", "code"); + if (format === "highlight") toggleWrap(editor, "==", "==", "important"); + if (format === "subscript") toggleWrap(editor, "~", "~", "2"); + if (format === "superscript") toggleWrap(editor, "^", "^", "2"); + if (format === "codeblock") toggleWrap(editor, "```text\n", "\n```", "code"); + if (format === "table") toggleWrap(editor, "| Column 1 | Column 2 |\n| --- | --- |\n| ", " | value |", "value"); + if (format === "footnote") toggleWrap(editor, "", "[^1]\n\n[^1]: Footnote text", "Text with footnote"); + if (format === "definition") toggleWrap(editor, "", "\n: Definition", "Term"); + if (format === "horizontal-rule") toggleWrap(editor, "\n---\n", "", ""); editor.focus(); editor.dispatchEvent(new Event("input", { bubbles: true })); } + +export function bindFormatShortcuts(editor) { + editor.addEventListener("keydown", event => { + const primary = event.ctrlKey || event.metaKey; + let format = null; + if (primary && !event.shiftKey && event.key.toLowerCase() === "b") format = "bold"; + else if (primary && !event.shiftKey && event.key.toLowerCase() === "i") format = "italic"; + else if (primary && event.shiftKey && event.key.toLowerCase() === "x") format = "strike"; + else if (primary && !event.shiftKey && event.key.toLowerCase() === "k") format = "link"; + else if (primary && event.shiftKey && event.key === "7") format = "number"; + else if (primary && event.shiftKey && event.key === "8") format = "bullet"; + else if (primary && event.shiftKey && event.key === "9") format = "task"; + else if (event.altKey && /^[1-4]$/.test(event.key)) format = `heading${event.key}`; + if (!format) return; + event.preventDefault(); + applyFormat(editor, format); + }); +} diff --git a/static/js/markdown.js b/static/js/markdown.js index 2cc2791..f376855 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -4,48 +4,181 @@ function escapeHtml(value) { function safeUrl(value) { const url = String(value).trim(); - if (/^(https?:\/\/|\/|\.\/|\.\.\/|#)/i.test(url)) return escapeHtml(url); + if (/^(https?:\/\/|mailto:|\/|\.\/|\.\.\/|#)/i.test(url)) return escapeHtml(url); return "#"; } +const emoji = { + smile:"😄", joy:"😂", heart:"❤️", thumbs_up:"👍", thumbsup:"👍", thumbs_down:"👎", + fire:"🔥", rocket:"🚀", tada:"🎉", warning:"⚠️", white_check_mark:"✅", x:"❌", + eyes:"👀", bulb:"💡", memo:"📝", pushpin:"📌", bug:"🐛", sparkles:"✨", + thinking:"🤔", clap:"👏", ok_hand:"👌", pray:"🙏", muscle:"💪", coffee:"☕", + tent:"⛺", star:"⭐", checkered_flag:"🏁" +}; + function inline(value) { const tokens = []; - let html = escapeHtml(value); - html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => { - const token = `\u0000IMG${tokens.length}\u0000`; - const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; - tokens.push(`${alt}`); + const stash = html => { + const token = `\u0000T${tokens.length}\u0000`; + tokens.push(html); return token; + }; + let html = escapeHtml(value); + + html = html.replace(/`([^`]+)`/g, (_, code) => stash(`${code}`)); + html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => { + const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; + return stash(`${alt}`); }); + html = html.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => { + const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; + return stash(`${label}`); + }); + html = html.replace(/\[\^([^\]\s]+)\]/g, (_, id) => stash(`?`)); + html = html - .replace(/`([^`]+)`/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/~~([^~]+)~~/g, "$1") + .replace(/==([^=]+)==/g, "$1") .replace(/\*([^*]+)\*/g, "$1") - .replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => { - const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; - return `${label}`; - }); - return html.replace(/\u0000IMG(\d+)\u0000/g, (_, index) => tokens[Number(index)] || ""); + .replace(/(?$1") + .replace(/\^([^^\n]+)\^/g, "$1") + .replace(/:([a-z0-9_+-]+):/gi, (match, name) => emoji[name] || match); + + html = html.replace(/(^|[\s(])((?:https?:\/\/|mailto:)[^\s<]+)/gi, (match, prefix, url) => { + const clean = url.replace(/[.,!?;:]+$/, ""); + const suffix = url.slice(clean.length); + return `${prefix}${stash(`${clean}`)}${suffix}`; + }); + + return html.replace(/\u0000T(\d+)\u0000/g, (_, index) => tokens[Number(index)] || ""); +} + +const attrs = (line, editable = false, prefix = "", suffix = "") => ` class="preview-source-line${editable ? " preview-editable" : ""}" data-source-line="${line + 1}"${editable ? ` contenteditable="true" spellcheck="true" data-source-prefix="${escapeHtml(prefix)}" data-source-suffix="${escapeHtml(suffix)}"` : ""}`; +const isPlainText = line => !/[`*_~^=\[\]<>|:#]/.test(line) && !/^\s*(?:[-+*>]|\d+\.)\s/.test(line); + +function splitTableRow(line) { + let value = line.trim(); + if (value.startsWith("|")) value = value.slice(1); + if (value.endsWith("|")) value = value.slice(0, -1); + return value.split("|").map(cell => cell.trim().replace(/|/g, "|")); +} + +function tableDelimiter(line) { + const cells = splitTableRow(line); + if (!cells.length || !cells.every(cell => /^:?-{3,}:?$/.test(cell))) return null; + return cells.map(cell => cell.startsWith(":") && cell.endsWith(":") ? "center" : cell.endsWith(":") ? "right" : "left"); } export function renderMarkdown(source) { - let html = "", inCode = false, language = "", code = [], list = null; + let html = "", inCode = false, fence = "", language = "", code = [], codeStart = 0, list = null; + const lines = String(source).split("\n"); + const footnotes = new Map(); + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(/^\[\^([^\]\s]+)\]:\s*(.*)$/); + if (!match) continue; + const body = [match[2]]; + let j = i + 1; + while (j < lines.length && /^(?: {4}|\t)/.test(lines[j])) { + body.push(lines[j].replace(/^(?: {4}|\t)/, "")); + lines[j] = ""; + j++; + } + footnotes.set(match[1], body.join("\n")); + lines[i] = ""; + } + const closeList = () => { if (list) { html += ``; list = null; } }; const closeCode = () => { const body = escapeHtml(code.join("\n")); html += language.toLowerCase() === "mermaid" - ? `
${body}
` - : `
${body}
`; - code = []; language = ""; + ? `
${body}
` + : `${body}`; + code = []; language = ""; fence = ""; }; - for (const line of String(source).split("\n")) { - if (line.startsWith("```")) { closeList(); if (inCode) closeCode(); else language = line.slice(3).trim(); inCode = !inCode; continue; } + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const fenceMatch = line.match(/^(```+|~~~+)\s*([^\s]*)\s*$/); + if (fenceMatch) { + closeList(); + if (inCode && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) closeCode(); + else if (!inCode) { fence = fenceMatch[1]; language = fenceMatch[2] || ""; codeStart = index; } + inCode = !inCode; + continue; + } if (inCode) { code.push(line); continue; } - const heading = line.match(/^(#{1,6})\s+(.+)$/), ul = line.match(/^\s*[-*+]\s+(.+)$/), ol = line.match(/^\s*\d+\.\s+(.+)$/); - if (heading) { closeList(); const n = heading[1].length; html += `${inline(heading[2])}`; } - else if (ul || ol) { const type = ul ? "ul" : "ol"; if (list !== type) { closeList(); html += `<${type}>`; list = type; } html += `
  • ${inline((ul || ol)[1])}
  • `; } - else { closeList(); if (/^---+$/.test(line)) html += "
    "; else if (line.startsWith("> ")) html += `
    ${inline(line.slice(2))}
    `; else if (line.trim()) html += `

    ${inline(line)}

    `; else html += "
    "; } + + const delimiter = index + 1 < lines.length ? tableDelimiter(lines[index + 1]) : null; + if (line.includes("|") && delimiter) { + closeList(); + const headers = splitTableRow(line); + html += `
    `; + headers.forEach((cell, i) => html += ``); + html += ``; + index += 2; + while (index < lines.length && lines[index].includes("|") && lines[index].trim()) { + const cells = splitTableRow(lines[index]); + html += ``; + headers.forEach((_, i) => html += ``); + html += ``; + index++; + } + html += `
    ${inline(cell)}
    ${inline(cells[i] || "")}
    `; + index--; + continue; + } + + const heading = line.match(/^(#{1,6})\s+(.+?)(?:\s+\{#([A-Za-z][\w:.-]*)\})?\s*$/); + const task = line.match(/^(\s*)[-*+]\s+\[([ xX])\]\s+(.+)$/); + const ul = line.match(/^\s*[-*+]\s+(.+)$/); + const ol = line.match(/^\s*\d+\.\s+(.+)$/); + + if (heading) { + closeList(); + const n = heading[1].length; + const id = heading[3] ? ` id="${escapeHtml(heading[3])}"` : ""; + const suffix = heading[3] ? ` {#${heading[3]}}` : ""; + html += `${inline(heading[2])}`; + } else if (task) { + if (list !== "ul") { closeList(); html += `
      `; list = "ul"; } + const checked = task[2].toLowerCase() === "x"; + html += `
    • ${inline(task[3])}
    • `; + } else if (ul || ol) { + const type = ul ? "ul" : "ol"; + if (list !== type) { closeList(); html += `<${type}>`; list = type; } + html += `${inline((ul || ol)[1])}`; + } else { + closeList(); + const definition = index + 1 < lines.length && /^:\s+/.test(lines[index + 1]); + if (line.trim() && definition) { + html += `
      ${inline(line)}
      `; + while (index + 1 < lines.length && /^:\s+/.test(lines[index + 1])) { + index++; + html += `
      ${inline(lines[index].replace(/^:\s+/, ""))}
      `; + } + html += ``; + } else if (/^---+$/.test(line.trim())) html += ``; + else if (line.startsWith("> ")) html += `${inline(line.slice(2))}`; + else if (line.trim()) html += `${inline(line)}

      `; + else html += `
      `; + } } - closeList(); if (inCode) closeCode(); return html; + + closeList(); + if (inCode) closeCode(); + + if (footnotes.size) { + html = html.replace(/\?<\/a><\/sup>/g, (_, id) => { + const order = [...footnotes.keys()].indexOf(id) + 1; + return `${order || "?"}`; + }); + html += `

        `; + for (const [id, body] of footnotes) { + html += `
      1. ${body.split("\n").map(part => inline(part)).join("
        ")}
      2. `; + } + html += `
      `; + } + return html; } diff --git a/static/js/note.js b/static/js/note.js index e9e4df2..9b648d4 100644 --- a/static/js/note.js +++ b/static/js/note.js @@ -1,6 +1,6 @@ import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; -import { applyFormat } from "@rustpad/editor-format"; +import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format"; import { renderMarkdown } from "@rustpad/markdown"; import { prepareImageFile } from "./image-upload.js"; import { getNickname, getPassword, setNickname, setPassword } from "@rustpad/session"; @@ -10,17 +10,18 @@ import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url const parts=location.pathname.split("/").filter(Boolean), workspaceSlug=parts[1], noteSlug=parts[3]; const editor=document.querySelector("#editor"), preview=document.querySelector("#preview"), editorWorkspace=document.querySelector("#editor-workspace"), gutter=document.querySelector("#line-gutter"), ownerLabels=document.querySelector("#owner-labels"); const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document.querySelector("#password-dialog"), identityDialog=document.querySelector("#identity-dialog"); -const compactToggle=document.querySelector("#compact-toggle"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"); +const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"); let password=getPassword(workspaceSlug), nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[]; const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off"; -compactToggle.checked=localStorage.getItem("rustpad:compact")==="on"; +compactToggle.checked=localStorage.getItem("rustpad:compact")!=="off"; fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono"; -fontSize.value=localStorage.getItem("rustpad:font-size")||"18"; +fontSize.value=localStorage.getItem("rustpad:font-size")||"14"; function colorFor(name){let h=0;for(const c of name||"?")h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 70% 62%)`;} function toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);} function setStatus(kind,text){document.querySelector("#status-dot").className=`status__dot${kind?` is-${kind}`:""}`;document.querySelector("#status-text").textContent=text;} function updateAddressLabel(){document.querySelector("#note-url").textContent=`${location.pathname}${location.search}`;} async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'

      Failed to load Mermaid.

      '));}} +async function renderCodeHighlight(){const nodes=preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)');if(!nodes.length)return;try{const hljs=await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm");nodes.forEach(node=>hljs.default.highlightElement(node));}catch{}} function renderGutter(){ const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1); const lines=Array.from({length:lineCount}); @@ -40,7 +41,7 @@ function renderGutter(){ document.body.classList.toggle("hide-line-numbers",!lineToggle.checked); } function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));}function formatDate(value){const raw=String(value??"").trim();let normalized=raw;if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized))normalized=normalized.replace(" ","T")+":00";else if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized))normalized=normalized.replace(" ","T");else if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized))normalized=normalized.replace(" ","T")+"Z";const date=new Date(normalized);return Number.isNaN(date.getTime())?raw:date.toLocaleString("pl-PL",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"});} -function render(){if(uiState.mode==="markdown"){preview.classList.remove("preview--raw");preview.innerHTML=renderMarkdown(editor.value);document.querySelector("#preview-label").textContent="Markdown + Mermaid preview";renderMermaid();}else{preview.classList.add("preview--raw");preview.textContent=editor.value;document.querySelector("#preview-label").textContent="Source text";}document.querySelector("#characters").textContent=`${editor.value.length} characters`;document.querySelector("#words").textContent=`${editor.value.trim()?editor.value.trim().split(/\s+/).length:0} words`;renderGutter();} +function render(){if(uiState.mode==="markdown"){preview.classList.remove("preview--raw");preview.innerHTML=renderMarkdown(editor.value);document.querySelector("#preview-label").textContent="Markdown + Mermaid preview · text and headings are editable";renderMermaid();renderCodeHighlight();}else{preview.classList.add("preview--raw");preview.innerHTML=editor.value.split("\n").map((line,index)=>`
      ${escapeHtml(line)||"
      "}
      `).join("");document.querySelector("#preview-label").textContent="Text preview · editable";}document.querySelector("#characters").textContent=`${editor.value.length} characters`;document.querySelector("#words").textContent=`${editor.value.trim()?editor.value.trim().split(/\s+/).length:0} words`;renderGutter();} function applyUi({write=false,replace=false}={}){editorWorkspace.className=`workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`;editorWorkspace.style.setProperty("--editor-font-size",`${fontSize.value}px`);document.body.classList.toggle("compact-editor",compactToggle.checked);document.querySelectorAll("[data-view]").forEach(b=>{const a=b.dataset.view===uiState.view;b.classList.toggle("active",a);b.setAttribute("aria-pressed",String(a));});const markdown=uiState.mode==="markdown";modeToggle.classList.toggle("active",markdown);modeToggle.textContent=markdown?"Markdown":"Text";render();if(write)writeEditorState(uiState,{replace});updateAddressLabel();} function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();} function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,nickname,onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();} @@ -55,11 +56,11 @@ async function loadFiles({open=false}={}){ if(open&&!document.querySelector("#files-dialog").open)document.querySelector("#files-dialog").showModal(); }catch(error){toast(error.message);} } -async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`

      Note not found

      ${escapeHtml(e.message)}

      `;}} +async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`

      Note not found

      ${escapeHtml(e.message)}

      `;}} document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}); document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();}); -window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>applyFormat(editor,b.dataset.format))); -document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null})});const url=new URL(result.url,location.origin).href;await copyText(url);toast("Page link copied");window.open(url,"_blank","noopener");}catch(error){toast(error.message);}}); +window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>{applyFormat(editor,b.dataset.format);b.closest("details")?.removeAttribute("open");}));bindFormatShortcuts(editor);document.querySelector("#shortcuts-button").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").showModal());document.querySelector("#close-shortcuts").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").close());preview.addEventListener("change",event=>{const checkbox=event.target.closest(".task-checkbox");if(!checkbox)return;const lineIndex=Number(checkbox.dataset.sourceLine)-1;const lines=editor.value.split("\n");if(lineIndex<0||lineIndex>=lines.length)return;lines[lineIndex]=lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/,`$1${checkbox.checked?"x":" "}$2`);editor.value=lines.join("\n");editor.dispatchEvent(new Event("input",{bubbles:true}));});preview.addEventListener("keydown",event=>{const target=event.target.closest(".preview-editable");if(!target)return;if(event.key==="Enter"){event.preventDefault();target.blur();}});preview.addEventListener("blur",event=>{const target=event.target.closest(".preview-editable");if(!target)return;const lineIndex=Number(target.dataset.sourceLine)-1;if(lineIndex<0)return;const lines=editor.value.split("\n");const prefix=target.dataset.sourcePrefix||"",suffix=target.dataset.sourceSuffix||"";const next=prefix+(target.innerText||"").replace(/\n/g," ")+suffix;if(lines[lineIndex]===next)return;lines[lineIndex]=next;editor.value=lines.join("\n");editor.setSelectionRange(editor.value.length,editor.value.length);editor.dispatchEvent(new Event("input",{bubbles:true}));},{capture:true}); +publicTaskUpdates.addEventListener("change",async()=>{publicTaskUpdates.disabled=true;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates:publicTaskUpdates.checked})});toast(publicTaskUpdates.checked?"Public task updates enabled":"Public task updates disabled");}catch(error){publicTaskUpdates.checked=!publicTaskUpdates.checked;toast(error.message);}finally{publicTaskUpdates.disabled=false;}});document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates:publicTaskUpdates.checked})});const url=new URL(result.url,location.origin).href;await copyText(url);toast("Page link copied");window.open(url,"_blank","noopener");}catch(error){toast(error.message);}}); editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.lengthsocket?.update(editor.value,JSON.stringify(owners)),250);}); document.querySelector("#password-form").addEventListener("submit",e=>{e.preventDefault();password=document.querySelector("#open-password").value;setPassword(workspaceSlug,password);document.querySelector("#password-error").textContent="";loadFiles();connect();}); const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='

      Loading…

      ';try{const revisions=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`,{method:"POST",body:JSON.stringify({password:password||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `
      ${escapeHtml(author)}

      ${snippet}

      `;}).join(""):'

      No history yet.

      ';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`,{method:"POST",body:JSON.stringify({password:password||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`

      ${escapeHtml(e.message)}

      `;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");}); diff --git a/static/js/pad.js b/static/js/pad.js index 780c8c7..a8a8600 100644 --- a/static/js/pad.js +++ b/static/js/pad.js @@ -1,6 +1,6 @@ import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; -import { applyFormat } from "@rustpad/editor-format"; +import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format"; import { renderMarkdown } from "@rustpad/markdown"; import { prepareImageFile } from "./image-upload.js"; import { getNickname, setNickname } from "@rustpad/session"; @@ -10,17 +10,18 @@ import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url const slug=location.pathname.split("/").filter(Boolean)[1]; const editor=document.querySelector("#editor"), preview=document.querySelector("#preview"), editorWorkspace=document.querySelector("#editor-workspace"), gutter=document.querySelector("#line-gutter"), ownerLabels=document.querySelector("#owner-labels"); const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document.querySelector("#password-dialog"), identityDialog=document.querySelector("#identity-dialog"); -const compactToggle=document.querySelector("#compact-toggle"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"); +const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"); let password=sessionStorage.getItem(`rustpad:pad:${slug}:password`)||"", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[]; const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off"; -compactToggle.checked=localStorage.getItem("rustpad:compact")==="on"; +compactToggle.checked=localStorage.getItem("rustpad:compact")!=="off"; fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono"; -fontSize.value=localStorage.getItem("rustpad:font-size")||"18"; +fontSize.value=localStorage.getItem("rustpad:font-size")||"14"; function colorFor(name){let h=0;for(const c of name||"?")h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 70% 62%)`;} function toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);} function setStatus(kind,text){document.querySelector("#status-dot").className=`status__dot${kind?` is-${kind}`:""}`;document.querySelector("#status-text").textContent=text;} function updateAddressLabel(){document.querySelector("#pad-url").textContent=`${location.pathname}${location.search}`;} async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'

      Failed to load Mermaid.

      '));}} +async function renderCodeHighlight(){const nodes=preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)');if(!nodes.length)return;try{const hljs=await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm");nodes.forEach(node=>hljs.default.highlightElement(node));}catch{}} function renderGutter(){ const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1); const lines=Array.from({length:lineCount}); @@ -40,7 +41,7 @@ function renderGutter(){ document.body.classList.toggle("hide-line-numbers",!lineToggle.checked); } function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));}function formatDate(value){const raw=String(value??"").trim();let normalized=raw;if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized))normalized=normalized.replace(" ","T")+":00";else if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized))normalized=normalized.replace(" ","T");else if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized))normalized=normalized.replace(" ","T")+"Z";const date=new Date(normalized);return Number.isNaN(date.getTime())?raw:date.toLocaleString("pl-PL",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"});} -function render(){if(uiState.mode==="markdown"){preview.classList.remove("preview--raw");preview.innerHTML=renderMarkdown(editor.value);document.querySelector("#preview-label").textContent="Markdown + Mermaid preview";renderMermaid();}else{preview.classList.add("preview--raw");preview.textContent=editor.value;document.querySelector("#preview-label").textContent="Source text";}document.querySelector("#characters").textContent=`${editor.value.length} characters`;document.querySelector("#words").textContent=`${editor.value.trim()?editor.value.trim().split(/\s+/).length:0} words`;renderGutter();} +function render(){if(uiState.mode==="markdown"){preview.classList.remove("preview--raw");preview.innerHTML=renderMarkdown(editor.value);document.querySelector("#preview-label").textContent="Markdown + Mermaid preview · text and headings are editable";renderMermaid();renderCodeHighlight();}else{preview.classList.add("preview--raw");preview.innerHTML=editor.value.split("\n").map((line,index)=>`
      ${escapeHtml(line)||"
      "}
      `).join("");document.querySelector("#preview-label").textContent="Text preview · editable";}document.querySelector("#characters").textContent=`${editor.value.length} characters`;document.querySelector("#words").textContent=`${editor.value.trim()?editor.value.trim().split(/\s+/).length:0} words`;renderGutter();} function applyUi({write=false,replace=false}={}){editorWorkspace.className=`workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`;editorWorkspace.style.setProperty("--editor-font-size",`${fontSize.value}px`);document.body.classList.toggle("compact-editor",compactToggle.checked);document.querySelectorAll("[data-view]").forEach(b=>{const a=b.dataset.view===uiState.view;b.classList.toggle("active",a);b.setAttribute("aria-pressed",String(a));});const markdown=uiState.mode==="markdown";modeToggle.classList.toggle("active",markdown);modeToggle.textContent=markdown?"Markdown":"Text";render();if(write)writeEditorState(uiState,{replace});updateAddressLabel();} function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();} @@ -53,11 +54,11 @@ async function loadFiles({open=false}={}){ }catch(error){if(open)toast(error.message);} } function connect(){socket?.stop();socket=new PadSocket({slug,password,nickname,onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();} -async function initialize(){try{info=await api(`/api/pads/${encodeURIComponent(slug)}`);document.title=`${info.title} · RustPad`;applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`

      Note not found

      ${escapeHtml(e.message)}

      `;}} +async function initialize(){try{info=await api(`/api/pads/${encodeURIComponent(slug)}`);document.title=`${info.title} · RustPad`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`

      Note not found

      ${escapeHtml(e.message)}

      `;}} document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}); document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();}); -window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>applyFormat(editor,b.dataset.format))); -document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null})});const url=new URL(result.url,location.origin).href;await copyText(url);toast("Page link copied");window.open(url,"_blank","noopener");}catch(error){toast(error.message);}}); +window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>{applyFormat(editor,b.dataset.format);b.closest("details")?.removeAttribute("open");}));bindFormatShortcuts(editor);document.querySelector("#shortcuts-button").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").showModal());document.querySelector("#close-shortcuts").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").close());preview.addEventListener("change",event=>{const checkbox=event.target.closest(".task-checkbox");if(!checkbox)return;const lineIndex=Number(checkbox.dataset.sourceLine)-1;const lines=editor.value.split("\n");if(lineIndex<0||lineIndex>=lines.length)return;lines[lineIndex]=lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/,`$1${checkbox.checked?"x":" "}$2`);editor.value=lines.join("\n");editor.dispatchEvent(new Event("input",{bubbles:true}));});preview.addEventListener("keydown",event=>{const target=event.target.closest(".preview-editable");if(!target)return;if(event.key==="Enter"){event.preventDefault();target.blur();}});preview.addEventListener("blur",event=>{const target=event.target.closest(".preview-editable");if(!target)return;const lineIndex=Number(target.dataset.sourceLine)-1;if(lineIndex<0)return;const lines=editor.value.split("\n");const prefix=target.dataset.sourcePrefix||"",suffix=target.dataset.sourceSuffix||"";const next=prefix+(target.innerText||"").replace(/\n/g," ")+suffix;if(lines[lineIndex]===next)return;lines[lineIndex]=next;editor.value=lines.join("\n");editor.setSelectionRange(editor.value.length,editor.value.length);editor.dispatchEvent(new Event("input",{bubbles:true}));},{capture:true}); +publicTaskUpdates.addEventListener("change",async()=>{publicTaskUpdates.disabled=true;try{await api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates:publicTaskUpdates.checked})});toast(publicTaskUpdates.checked?"Public task updates enabled":"Public task updates disabled");}catch(error){publicTaskUpdates.checked=!publicTaskUpdates.checked;toast(error.message);}finally{publicTaskUpdates.disabled=false;}});document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates:publicTaskUpdates.checked})});const url=new URL(result.url,location.origin).href;await copyText(url);toast("Page link copied");window.open(url,"_blank","noopener");}catch(error){toast(error.message);}}); editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.lengthsocket?.update(editor.value,JSON.stringify(owners)),250);}); document.querySelector("#password-form").addEventListener("submit",e=>{e.preventDefault();password=document.querySelector("#open-password").value;sessionStorage.setItem(`rustpad:pad:${slug}:password`,password);document.querySelector("#password-error").textContent="";connect();}); const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='

      Loading…

      ';try{const revisions=await api(`/api/pads/${encodeURIComponent(slug)}/history`,{method:"POST",body:JSON.stringify({password:password||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `
      ${escapeHtml(author)}

      ${snippet}

      `;}).join(""):'

      No history yet.

      ';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/pads/${encodeURIComponent(slug)}/restore`,{method:"POST",body:JSON.stringify({password:password||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`

      ${escapeHtml(e.message)}

      `;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");}); diff --git a/static/js/public.js b/static/js/public.js index 71864f0..7228242 100644 --- a/static/js/public.js +++ b/static/js/public.js @@ -2,10 +2,17 @@ import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; import { renderMarkdown } from "@rustpad/markdown"; -const token = location.pathname.split("/").filter(Boolean)[1]; -const content = document.querySelector("#public-content"); +const token=location.pathname.split("/").filter(Boolean)[1]; +const content=document.querySelector("#public-content"); function toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);} async function renderMermaid(){const nodes=content.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'

      Failed to load Mermaid.

      '));}} -async function initialize(){try{const page=await api(`/api/public/${encodeURIComponent(token)}`);document.querySelector("#public-title").textContent=page.title;document.querySelector("#public-meta").textContent=`Updated: ${new Date(page.updated_at).toLocaleString("en-US")}`;document.title=`${page.title} · RustPad`;content.innerHTML=renderMarkdown(page.content);await renderMermaid();}catch(error){content.innerHTML=`

      ${String(error.message)}

      `;}} +async function renderCodeHighlight(){const blocks=content.querySelectorAll('pre code[class^="language-"]');if(!blocks.length)return;try{const hljs=await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm");blocks.forEach(block=>hljs.default.highlightElement(block));}catch{}} +function lockPublicContent(allowTaskUpdates){ + content.querySelectorAll('[contenteditable]').forEach(node=>node.removeAttribute('contenteditable')); + content.querySelectorAll('.preview-editable').forEach(node=>node.classList.remove('preview-editable')); + content.querySelectorAll('.task-checkbox').forEach(box=>{box.disabled=!allowTaskUpdates;box.title=allowTaskUpdates?'Update this task':'Task updates are disabled by the owner';}); +} +async function initialize(){try{const page=await api(`/api/public/${encodeURIComponent(token)}`);document.querySelector("#public-title").textContent=page.title;document.querySelector("#public-meta").textContent=`Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates?" · tasks can be updated":""}`;document.title=`${page.title} · RustPad`;content.innerHTML=renderMarkdown(page.content);lockPublicContent(page.allow_task_updates);await Promise.all([renderMermaid(),renderCodeHighlight()]);}catch(error){content.innerHTML=`

      ${String(error.message)}

      `;}} +content.addEventListener("change",async event=>{const box=event.target.closest(".task-checkbox");if(!box||box.disabled)return;const previous=!box.checked;box.disabled=true;try{const page=await api(`/api/public/${encodeURIComponent(token)}/tasks`,{method:"POST",body:JSON.stringify({source_line:Number(box.dataset.sourceLine),checked:box.checked})});document.querySelector("#public-meta").textContent=`Updated: ${new Date(page.updated_at).toLocaleString("en-US")} · tasks can be updated`;toast("Task saved");}catch(error){box.checked=previous;toast(error.message);}finally{box.disabled=false;}}); document.querySelector("#copy-public-link").addEventListener("click",async()=>{try{await copyText(location.href);toast("Link copied");}catch(error){toast(error.message);}}); initialize(); diff --git a/static/note.html b/static/note.html index b9804d2..5989037 100644 --- a/static/note.html +++ b/static/note.html @@ -1,5 +1,5 @@ __NOTE_TITLE__ · RustPad -
      __WORKSPACE_TITLE__

      __NOTE_TITLE__

      Connecting…
      -
      Editor
      Markdown preview
      0 characters · 0 words
      · Changes are saved automatically
      -

      Note files

      Copy a direct link or ready Markdown/HTML code.

      What should we call you?

      Your name will be shown next to changes and remembered on this device.

      +
      __WORKSPACE_TITLE__

      __NOTE_TITLE__

      +
      More
      Editor
      Markdown preview
      · · Changes are saved automatically
      +

      Keyboard shortcuts

      Use Ctrl on Windows/Linux or Cmd on macOS.

      Ctrl/Cmd+BBoldCtrl/Cmd+IItalicCtrl/Cmd+Shift+XStrikethroughCtrl/Cmd+KLinkCtrl/Cmd+Shift+7Numbered listCtrl/Cmd+Shift+8Bullet listCtrl/Cmd+Shift+9Task listAlt+1…4Headings H1–H4

      Note files

      Copy a direct link or ready Markdown/HTML code.

      What should we call you?

      Your name will be shown next to changes and remembered on this device.

      Protected workspace

      Back
      diff --git a/static/pad.html b/static/pad.html index fce0954..f64c2aa 100644 --- a/static/pad.html +++ b/static/pad.html @@ -1,5 +1,5 @@ __PAD_TITLE__ · RustPad -
      RustPad

      __PAD_TITLE__

      Connecting…
      -
      Editor
      Markdown preview
      0 characters · 0 words
      · Changes are saved automatically
      -

      Note files

      Copy a direct link or ready Markdown/HTML code.

      What should we call you?

      Your name will be shown next to changes and remembered on this device.

      +
      RustPad

      __PAD_TITLE__

      +
      More
      Editor
      Markdown preview
      · · Changes are saved automatically
      +

      Keyboard shortcuts

      Use Ctrl on Windows/Linux or Cmd on macOS.

      Ctrl/Cmd+BBoldCtrl/Cmd+IItalicCtrl/Cmd+Shift+XStrikethroughCtrl/Cmd+KLinkCtrl/Cmd+Shift+7Numbered listCtrl/Cmd+Shift+8Bullet listCtrl/Cmd+Shift+9Task listAlt+1…4Headings H1–H4

      Note files

      Copy a direct link or ready Markdown/HTML code.

      What should we call you?

      Your name will be shown next to changes and remembered on this device.

      Protected note

      Back