new functions

This commit is contained in:
Mateusz Gruszczyński
2026-07-21 23:58:01 +02:00
parent fd2641780b
commit fa8a0d687f
18 changed files with 478 additions and 79 deletions
+1
View File
@@ -1,3 +1,4 @@
__pycache__
/target /target
rustpad.db rustpad.db
rustpad.db-shm rustpad.db-shm
Binary file not shown.
Executable → Regular
View File
@@ -0,0 +1 @@
ALTER TABLE published_pages ADD COLUMN allow_task_updates BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1 @@
ALTER TABLE published_pages ADD COLUMN allow_task_updates BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1 @@
ALTER TABLE published_pages ADD COLUMN allow_task_updates INTEGER NOT NULL DEFAULT 0;
+43 -2
View File
@@ -27,6 +27,7 @@ pub struct PublicPageResponse {
title: String, title: String,
content: String, content: String,
updated_at: String, updated_at: String,
allow_task_updates: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -48,6 +49,20 @@ pub struct PasswordRequest {
password: Option<String>, password: Option<String>,
} }
#[derive(Debug, Deserialize)]
pub struct PublishRequest {
#[serde(default)]
password: Option<String>,
#[serde(default)]
allow_task_updates: bool,
}
#[derive(Debug, Deserialize)]
pub struct PublicTaskUpdateRequest {
source_line: usize,
checked: bool,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateNoteRequest { pub struct CreateNoteRequest {
name: String, name: String,
@@ -100,6 +115,7 @@ pub struct NoteInfo {
title: String, title: String,
protected: bool, protected: bool,
note_protected: bool, note_protected: bool,
allow_public_task_updates: bool,
created_at: String, created_at: String,
updated_at: String, updated_at: String,
} }
@@ -206,6 +222,7 @@ pub async fn note_info(
title: note.title, title: note.title,
protected: workspace.password_hash.is_some(), protected: workspace.password_hash.is_some(),
note_protected: note.protected, note_protected: note.protected,
allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?,
created_at: db::normalize_timestamp(&note.created_at), created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at), updated_at: db::normalize_timestamp(&note.updated_at),
})) }))
@@ -374,6 +391,7 @@ pub struct PadInfo {
slug: String, slug: String,
title: String, title: String,
protected: bool, protected: bool,
allow_public_task_updates: bool,
created_at: String, created_at: String,
updated_at: String, updated_at: String,
} }
@@ -410,6 +428,7 @@ pub async fn pad_info(
slug: pad.slug, slug: pad.slug,
title: pad.title, title: pad.title,
protected: pad.password_hash.is_some(), 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), created_at: db::normalize_timestamp(&pad.created_at),
updated_at: db::normalize_timestamp(&pad.updated_at), updated_at: db::normalize_timestamp(&pad.updated_at),
})) }))
@@ -418,20 +437,22 @@ pub async fn pad_info(
pub async fn publish_pad_page( pub async fn publish_pad_page(
State(state): State<SharedState>, State(state): State<SharedState>,
Path(slug): Path<String>, Path(slug): Path<String>,
Json(payload): Json<PasswordRequest>, Json(payload): Json<PublishRequest>,
) -> Result<Json<PublishResponse>, ApiError> { ) -> Result<Json<PublishResponse>, ApiError> {
let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?; let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?;
let token = db::publish_pad(&state.db, pad.id).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}") })) Ok(Json(PublishResponse { url: format!("/s/{token}") }))
} }
pub async fn publish_note_page( pub async fn publish_note_page(
State(state): State<SharedState>, State(state): State<SharedState>,
Path((workspace_slug, note_slug)): Path<(String, String)>, Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<PasswordRequest>, Json(payload): Json<PublishRequest>,
) -> Result<Json<PublishResponse>, ApiError> { ) -> Result<Json<PublishResponse>, ApiError> {
let (_, note) = authorized_note(&state, &workspace_slug, &note_slug, payload.password.as_deref()).await?; let (_, note) = authorized_note(&state, &workspace_slug, &note_slug, payload.password.as_deref()).await?;
let token = db::publish_note(&state.db, note.id).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}") })) Ok(Json(PublishResponse { url: format!("/s/{token}") }))
} }
@@ -446,6 +467,23 @@ pub async fn public_page(
title: page.title, title: page.title,
content: page.content, content: page.content,
updated_at: db::normalize_timestamp(&page.updated_at), updated_at: db::normalize_timestamp(&page.updated_at),
allow_task_updates: page.allow_task_updates,
}))
}
pub async fn update_public_task(
State(state): State<SharedState>,
Path(token): Path<String>,
Json(payload): Json<PublicTaskUpdateRequest>,
) -> Result<Json<PublicPageResponse>, 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(), message: "Invalid password".into(),
} }
} }
fn forbidden(message: &str) -> Self {
Self { status: StatusCode::FORBIDDEN, message: message.into() }
}
fn not_found_workspace() -> Self { fn not_found_workspace() -> Self {
Self { Self {
status: StatusCode::NOT_FOUND, status: StatusCode::NOT_FOUND,
+1
View File
@@ -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("/f/{token}/{filename}", get(api::download_file))
.route("/files/{directory}/{filename}", get(api::download_legacy_file)) .route("/files/{directory}/{filename}", get(api::download_legacy_file))
.route("/api/public/{token}", get(api::public_page)) .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", post(api::create_pad))
.route("/api/pads/{slug}", get(api::pad_info)) .route("/api/pads/{slug}", get(api::pad_info))
.route("/api/pads/{slug}/history", post(api::pad_history)) .route("/api/pads/{slug}/history", post(api::pad_history))
+84 -3
View File
@@ -382,14 +382,42 @@ pub async fn list_pad_revisions(
.await .await
} }
#[derive(Debug, Clone, Serialize, FromRow)] #[derive(Debug, Clone, Serialize)]
pub struct PublishedPage { pub struct PublishedPage {
pub token: String, pub token: String,
pub pad_id: Option<i64>,
pub note_id: Option<i64>,
pub allow_task_updates: bool,
pub title: String, pub title: String,
pub content: String, pub content: String,
pub updated_at: String, pub updated_at: String,
} }
#[derive(Debug, Clone, FromRow)]
struct PublishedPageRow {
token: String,
pad_id: Option<i64>,
note_id: Option<i64>,
allow_task_updates: i64,
title: String,
content: String,
updated_at: String,
}
impl From<PublishedPageRow> 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<String, sqlx::Error> { pub async fn publish_pad(pool: &Database, pad_id: i64) -> Result<String, sqlx::Error> {
if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q017)) if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q017))
.bind(pad_id) .bind(pad_id)
@@ -425,10 +453,63 @@ pub async fn publish_note(pool: &Database, note_id: i64) -> Result<String, sqlx:
} }
pub async fn find_published_page(pool: &Database, token: &str) -> Result<Option<PublishedPage>, sqlx::Error> { pub async fn find_published_page(pool: &Database, token: &str) -> Result<Option<PublishedPage>, sqlx::Error> {
sqlx::query_as::<_, PublishedPage>(queries::get(pool.kind(), queries::Q021)) Ok(sqlx::query_as::<_, PublishedPageRow>(queries::get(pool.kind(), queries::Q021))
.bind(token) .bind(token)
.fetch_optional(pool.pool()) .fetch_optional(pool.pool())
.await .await?
.map(Into::into))
}
pub async fn pad_public_task_updates(pool: &Database, pad_id: i64) -> Result<bool, sqlx::Error> {
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<bool, sqlx::Error> {
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<Option<PublishedPage>, 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<String> = 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<String, sqlx::Error> { pub async fn pad_file_token(pool: &Database, pad_id: i64) -> Result<String, sqlx::Error> {
+9 -1
View File
@@ -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 Q018: &str = "INSERT INTO published_pages (token, pad_id) VALUES (?, ?)";
pub const Q019: &str = "SELECT token FROM published_pages WHERE note_id = ?"; 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 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 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 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 = ?"; 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); cache.insert(cache_key, converted);
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 = ?";
+71
View File
@@ -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; } .note-delete-button:disabled { cursor: not-allowed; color: var(--muted-2); opacity: .55; }
.notes-view-switch button { cursor: pointer; } .notes-view-switch button { cursor: pointer; }
.notes-view-switch button.active { cursor: default; } .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; }
+72 -21
View File
@@ -1,26 +1,77 @@
export function applyFormat(editor, format) { function selection(editor) {
const wrap = (before, after = before, placeholder = "tekst") => { return { start: editor.selectionStart, end: editor.selectionEnd };
const start = editor.selectionStart, end = editor.selectionEnd; }
const selected = editor.value.slice(start, end) || placeholder;
editor.setRangeText(before + selected + after, start, end, "select"); function toggleWrap(editor, before, after = before, placeholder = "tekst") {
}; let { start, end } = selection(editor);
const prefix = (value) => { const value = editor.value;
const start = editor.selectionStart, end = editor.selectionEnd; 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 lineStart = editor.value.lastIndexOf("\n", start - 1) + 1;
const selected = editor.value.slice(lineStart, end); 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"); const lines = selected.split("\n");
}; const prefixes = lines.map((_, index) => typeof prefixFactory === "function" ? prefixFactory(index) : prefixFactory);
if (format === "bold") wrap("**"); const allPrefixed = lines.every((line, index) => line.startsWith(prefixes[index]));
if (format === "italic") wrap("*"); const replacement = lines.map((line, index) => allPrefixed ? line.slice(prefixes[index].length) : prefixes[index] + line).join("\n");
if (format === "strike") wrap("~~"); editor.setRangeText(replacement, lineStart, end, "select");
if (format === "heading" || format === "heading2") prefix("## "); }
if (format === "heading1") prefix("# ");
if (format === "heading3") prefix("### "); export function applyFormat(editor, format) {
if (format === "heading4") prefix("#### "); if (format === "bold") toggleWrap(editor, "**");
if (format === "bullet") prefix("- "); if (format === "italic") toggleWrap(editor, "*");
if (format === "number") prefix((index) => `${index + 1}. `); if (format === "strike") toggleWrap(editor, "~~");
if (format === "quote") prefix("> "); if (format === "heading" || format === "heading2") togglePrefix(editor, "## ");
if (format === "link") wrap("[", "](https://)", "description"); 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.focus();
editor.dispatchEvent(new Event("input", { bubbles: true })); 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);
});
}
+156 -23
View File
@@ -4,48 +4,181 @@ function escapeHtml(value) {
function safeUrl(value) { function safeUrl(value) {
const url = String(value).trim(); const url = String(value).trim();
if (/^(https?:\/\/|\/|\.\/|\.\.\/|#)/i.test(url)) return escapeHtml(url); if (/^(https?:\/\/|mailto:|\/|\.\/|\.\.\/|#)/i.test(url)) return escapeHtml(url);
return "#"; 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) { function inline(value) {
const tokens = []; const tokens = [];
let html = escapeHtml(value); const stash = html => {
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => { const token = `\u0000T${tokens.length}\u0000`;
const token = `\u0000IMG${tokens.length}\u0000`; tokens.push(html);
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
tokens.push(`<img src="${safeUrl(url)}" alt="${alt}" loading="lazy" decoding="async"${titleAttr}>`);
return token; return token;
};
let html = escapeHtml(value);
html = html.replace(/`([^`]+)`/g, (_, code) => stash(`<code>${code}</code>`));
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return stash(`<img src="${safeUrl(url)}" alt="${alt}" loading="lazy" decoding="async"${titleAttr}>`);
}); });
html = html.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return stash(`<a href="${safeUrl(url)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${label}</a>`);
});
html = html.replace(/\[\^([^\]\s]+)\]/g, (_, id) => stash(`<sup class="footnote-ref"><a href="#fn-${escapeHtml(id)}" id="fnref-${escapeHtml(id)}">?</a></sup>`));
html = html html = html
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/~~([^~]+)~~/g, "<s>$1</s>") .replace(/~~([^~]+)~~/g, "<s>$1</s>")
.replace(/==([^=]+)==/g, "<mark>$1</mark>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>") .replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => { .replace(/(?<!~)~([^~\n]+)~(?!~)/g, "<sub>$1</sub>")
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; .replace(/\^([^^\n]+)\^/g, "<sup>$1</sup>")
return `<a href="${safeUrl(url)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${label}</a>`; .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(`<a href="${safeUrl(clean)}" target="_blank" rel="noopener noreferrer">${clean}</a>`)}${suffix}`;
}); });
return html.replace(/\u0000IMG(\d+)\u0000/g, (_, index) => tokens[Number(index)] || "");
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(/&#124;/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) { 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}>`; list = null; } }; const closeList = () => { if (list) { html += `</${list}>`; list = null; } };
const closeCode = () => { const closeCode = () => {
const body = escapeHtml(code.join("\n")); const body = escapeHtml(code.join("\n"));
html += language.toLowerCase() === "mermaid" html += language.toLowerCase() === "mermaid"
? `<div class="mermaid">${body}</div>` ? `<div class="mermaid preview-source-line" data-source-line="${codeStart + 1}">${body}</div>`
: `<pre><code class="language-${escapeHtml(language)}">${body}</code></pre>`; : `<pre${attrs(codeStart)}><code class="language-${escapeHtml(language)}">${body}</code></pre>`;
code = []; language = ""; 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++) {
if (inCode) { code.push(line); continue; } const line = lines[index];
const heading = line.match(/^(#{1,6})\s+(.+)$/), ul = line.match(/^\s*[-*+]\s+(.+)$/), ol = line.match(/^\s*\d+\.\s+(.+)$/); const fenceMatch = line.match(/^(```+|~~~+)\s*([^\s]*)\s*$/);
if (heading) { closeList(); const n = heading[1].length; html += `<h${n}>${inline(heading[2])}</h${n}>`; } if (fenceMatch) {
else if (ul || ol) { const type = ul ? "ul" : "ol"; if (list !== type) { closeList(); html += `<${type}>`; list = type; } html += `<li>${inline((ul || ol)[1])}</li>`; } closeList();
else { closeList(); if (/^---+$/.test(line)) html += "<hr>"; else if (line.startsWith("> ")) html += `<blockquote>${inline(line.slice(2))}</blockquote>`; else if (line.trim()) html += `<p>${inline(line)}</p>`; else html += "<br>"; } 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;
} }
closeList(); if (inCode) closeCode(); return html; if (inCode) { code.push(line); continue; }
const delimiter = index + 1 < lines.length ? tableDelimiter(lines[index + 1]) : null;
if (line.includes("|") && delimiter) {
closeList();
const headers = splitTableRow(line);
html += `<div class="table-wrap preview-source-line" data-source-line="${index + 1}"><table><thead><tr>`;
headers.forEach((cell, i) => html += `<th style="text-align:${delimiter[i] || "left"}">${inline(cell)}</th>`);
html += `</tr></thead><tbody>`;
index += 2;
while (index < lines.length && lines[index].includes("|") && lines[index].trim()) {
const cells = splitTableRow(lines[index]);
html += `<tr>`;
headers.forEach((_, i) => html += `<td style="text-align:${delimiter[i] || "left"}">${inline(cells[i] || "")}</td>`);
html += `</tr>`;
index++;
}
html += `</tbody></table></div>`;
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 += `<h${n}${id}${attrs(index, true, `${heading[1]} `, suffix)}>${inline(heading[2])}</h${n}>`;
} else if (task) {
if (list !== "ul") { closeList(); html += `<ul class="task-list">`; list = "ul"; }
const checked = task[2].toLowerCase() === "x";
html += `<li class="preview-source-line task-list-item" data-source-line="${index + 1}"><input type="checkbox" class="task-checkbox" data-source-line="${index + 1}"${checked ? " checked" : ""}><span>${inline(task[3])}</span></li>`;
} else if (ul || ol) {
const type = ul ? "ul" : "ol";
if (list !== type) { closeList(); html += `<${type}>`; list = type; }
html += `<li${attrs(index)}>${inline((ul || ol)[1])}</li>`;
} else {
closeList();
const definition = index + 1 < lines.length && /^:\s+/.test(lines[index + 1]);
if (line.trim() && definition) {
html += `<dl${attrs(index)}><dt>${inline(line)}</dt>`;
while (index + 1 < lines.length && /^:\s+/.test(lines[index + 1])) {
index++;
html += `<dd data-source-line="${index + 1}">${inline(lines[index].replace(/^:\s+/, ""))}</dd>`;
}
html += `</dl>`;
} else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index)}>`;
else if (line.startsWith("> ")) html += `<blockquote${attrs(index)}>${inline(line.slice(2))}</blockquote>`;
else if (line.trim()) html += `<p${attrs(index, isPlainText(line))}>${inline(line)}</p>`;
else html += `<div${attrs(index, true)}><br></div>`;
}
}
closeList();
if (inCode) closeCode();
if (footnotes.size) {
html = html.replace(/<sup class="footnote-ref"><a href="#fn-([^"]+)" id="fnref-\1">\?<\/a><\/sup>/g, (_, id) => {
const order = [...footnotes.keys()].indexOf(id) + 1;
return `<sup class="footnote-ref"><a href="#fn-${id}" id="fnref-${id}">${order || "?"}</a></sup>`;
});
html += `<section class="footnotes"><hr><ol>`;
for (const [id, body] of footnotes) {
html += `<li id="fn-${escapeHtml(id)}">${body.split("\n").map(part => inline(part)).join("<br>")} <a class="footnote-backref" href="#fnref-${escapeHtml(id)}" aria-label="Back to reference">↩</a></li>`;
}
html += `</ol></section>`;
}
return html;
} }
+9 -8
View File
@@ -1,6 +1,6 @@
import { api } from "@rustpad/api"; import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard"; import { copyText } from "@rustpad/clipboard";
import { applyFormat } from "@rustpad/editor-format"; import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { renderMarkdown } from "@rustpad/markdown"; import { renderMarkdown } from "@rustpad/markdown";
import { prepareImageFile } from "./image-upload.js"; import { prepareImageFile } from "./image-upload.js";
import { getNickname, getPassword, setNickname, setPassword } from "@rustpad/session"; 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 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 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 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=[]; 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"; 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"; 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 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 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 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}`;} 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",'<p class="error">Failed to load Mermaid.</p>'));}} 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",'<p class="error">Failed to load Mermaid.</p>'));}}
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(){ function renderGutter(){
const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1); const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1);
const lines=Array.from({length:lineCount}); const lines=Array.from({length:lineCount});
@@ -40,7 +41,7 @@ function renderGutter(){
document.body.classList.toggle("hide-line-numbers",!lineToggle.checked); document.body.classList.toggle("hide-line-numbers",!lineToggle.checked);
} }
function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[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 escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[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)=>`<div class="preview-source-line preview-editable" data-source-line="${index+1}" contenteditable="true" spellcheck="true">${escapeHtml(line)||"<br>"}</div>`).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 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 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();} 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(); if(open&&!document.querySelector("#files-dialog").open)document.querySelector("#files-dialog").showModal();
}catch(error){toast(error.message);} }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=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}} 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=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
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.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();}); 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))); 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});
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);}}); 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.length<newLines)owners.push(nickname);owners=owners.slice(0,newLines);owners[cursorLine]=nickname;render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);}); 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.length<newLines)owners.push(nickname);owners=owners.slice(0,newLines);owners[cursorLine]=nickname;render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.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();}); 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='<p class="empty">Loading…</p>';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 `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`;}).join(""):'<p class="empty">No history yet.</p>';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=`<p class="error">${escapeHtml(e.message)}</p>`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");}); 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='<p class="empty">Loading…</p>';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 `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`;}).join(""):'<p class="empty">No history yet.</p>';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=`<p class="error">${escapeHtml(e.message)}</p>`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");});
+9 -8
View File
@@ -1,6 +1,6 @@
import { api } from "@rustpad/api"; import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard"; import { copyText } from "@rustpad/clipboard";
import { applyFormat } from "@rustpad/editor-format"; import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { renderMarkdown } from "@rustpad/markdown"; import { renderMarkdown } from "@rustpad/markdown";
import { prepareImageFile } from "./image-upload.js"; import { prepareImageFile } from "./image-upload.js";
import { getNickname, setNickname } from "@rustpad/session"; 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 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 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 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=[]; 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"; 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"; 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 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 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 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}`;} 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",'<p class="error">Failed to load Mermaid.</p>'));}} 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",'<p class="error">Failed to load Mermaid.</p>'));}}
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(){ function renderGutter(){
const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1); const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1);
const lines=Array.from({length:lineCount}); const lines=Array.from({length:lineCount});
@@ -40,7 +41,7 @@ function renderGutter(){
document.body.classList.toggle("hide-line-numbers",!lineToggle.checked); document.body.classList.toggle("hide-line-numbers",!lineToggle.checked);
} }
function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[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 escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[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)=>`<div class="preview-source-line preview-editable" data-source-line="${index+1}" contenteditable="true" spellcheck="true">${escapeHtml(line)||"<br>"}</div>`).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 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 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);} }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();} 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=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}} 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=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
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.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();}); 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))); 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});
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);}}); 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.length<newLines)owners.push(nickname);owners=owners.slice(0,newLines);owners[cursorLine]=nickname;render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);}); 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.length<newLines)owners.push(nickname);owners=owners.slice(0,newLines);owners[cursorLine]=nickname;render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.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();}); 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='<p class="empty">Loading…</p>';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 `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`;}).join(""):'<p class="empty">No history yet.</p>';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=`<p class="error">${escapeHtml(e.message)}</p>`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");}); 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='<p class="empty">Loading…</p>';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 `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`;}).join(""):'<p class="empty">No history yet.</p>';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=`<p class="error">${escapeHtml(e.message)}</p>`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");});
+10 -3
View File
@@ -2,10 +2,17 @@ import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard"; import { copyText } from "@rustpad/clipboard";
import { renderMarkdown } from "@rustpad/markdown"; import { renderMarkdown } from "@rustpad/markdown";
const token = location.pathname.split("/").filter(Boolean)[1]; const token=location.pathname.split("/").filter(Boolean)[1];
const content = document.querySelector("#public-content"); 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);} 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",'<p class="error">Failed to load Mermaid.</p>'));}} 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",'<p class="error">Failed to load Mermaid.</p>'));}}
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=`<p class="error">${String(error.message)}</p>`;}} 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=`<p class="error">${String(error.message)}</p>`;}}
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);}}); document.querySelector("#copy-public-link").addEventListener("click",async()=>{try{await copyText(location.href);toast("Link copied");}catch(error){toast(error.message);}});
initialize(); initialize();
+3 -3
View File
@@ -1,5 +1,5 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__NOTE_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/note.js?v=__ASSET_VERSION__"></script></head> <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__NOTE_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/note.js?v=__ASSET_VERSION__"></script></head>
<body class="pad-page"><header class="app-header"><div class="app-header__main"><a id="workspace-link" class="brand" href="/w/__WORKSPACE_SLUG__">__WORKSPACE_TITLE__</a><span class="header-divider"></span><div class="document-heading"><h1 id="note-title">__NOTE_TITLE__</h1><p id="note-url" class="document-url"></p></div></div><div class="header-actions"><span id="current-user" class="user-chip"></span><div class="status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></div><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><button id="files-button" class="secondary-button">Files</button><button id="delete-note" class="secondary-button danger-button" hidden>Delete</button><button id="history-button" class="secondary-button">History</button></div></header> <body class="pad-page"><header class="app-header"><div class="app-header__main"><a id="workspace-link" class="brand" href="/w/__WORKSPACE_SLUG__">__WORKSPACE_TITLE__</a><span class="header-divider"></span><div class="document-heading"><h1 id="note-title">__NOTE_TITLE__</h1><p id="note-url" class="document-url"></p></div></div><div class="header-actions"><span id="current-user" class="user-chip"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><label class="public-task-toggle" title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates" type="checkbox"> Editable tasks on Page</label><button id="files-button" class="secondary-button">Files</button><button id="delete-note" class="secondary-button danger-button" hidden>Delete</button><button id="history-button" class="secondary-button">History</button></div></header>
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number">1. List</button><button data-format="quote">Quote</button><button data-format="link">Link</button></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14">14</option><option value="16">16</option><option value="18" selected>18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox"> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div><span id="characters">0 characters</span> · <span id="words">0 words</span></div><span><button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main> <main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" class="dialog-panel"><h2>What should we call you?</h2><p class="dialog-copy">Your name will be shown next to changes and remembered on this device.</p><input id="nickname" maxlength="40" autocomplete="nickname" required placeholder="Name or nickname"><button class="primary-button">Open note</button></form></dialog> <dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" class="dialog-panel"><h2>What should we call you?</h2><p class="dialog-copy">Your name will be shown next to changes and remembered on this device.</p><input id="nickname" maxlength="40" autocomplete="nickname" required placeholder="Name or nickname"><button class="primary-button">Open note</button></form></dialog>
<dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a id="back-workspace" class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html> <dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a id="back-workspace" class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html>
+3 -3
View File
@@ -1,5 +1,5 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__PAD_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/pad.js?v=__ASSET_VERSION__"></script></head> <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__PAD_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/pad.js?v=__ASSET_VERSION__"></script></head>
<body class="pad-page"><header class="app-header"><div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span><div class="document-heading"><h1 id="pad-title">__PAD_TITLE__</h1><p id="pad-url" class="document-url"></p></div></div><div class="header-actions"><span id="current-user" class="user-chip"></span><div class="status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></div><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><button id="files-button" class="secondary-button">Files</button><button id="history-button" class="secondary-button">History</button></div></header> <body class="pad-page"><header class="app-header"><div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span><div class="document-heading"><h1 id="pad-title">__PAD_TITLE__</h1><p id="pad-url" class="document-url"></p></div></div><div class="header-actions"><span id="current-user" class="user-chip"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><label class="public-task-toggle" title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates" type="checkbox"> Editable tasks on Page</label><button id="files-button" class="secondary-button">Files</button><button id="history-button" class="secondary-button">History</button></div></header>
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number">1. List</button><button data-format="quote">Quote</button><button data-format="link">Link</button></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14">14</option><option value="16">16</option><option value="18" selected>18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox"> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div><span id="characters">0 characters</span> · <span id="words">0 words</span></div><span><button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main> <main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" class="dialog-panel"><h2>What should we call you?</h2><p class="dialog-copy">Your name will be shown next to changes and remembered on this device.</p><input id="nickname" maxlength="40" autocomplete="nickname" required placeholder="Name or nickname"><button class="primary-button">Open note</button></form></dialog> <dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" class="dialog-panel"><h2>What should we call you?</h2><p class="dialog-copy">Your name will be shown next to changes and remembered on this device.</p><input id="nickname" maxlength="40" autocomplete="nickname" required placeholder="Name or nickname"><button class="primary-button">Open note</button></form></dialog>
<dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected note</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html> <dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected note</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html>