new finctions and fixes

This commit is contained in:
Mateusz Gruszczyński
2026-07-30 00:12:48 +02:00
parent 9ca055de5f
commit 2274cf57c9
31 changed files with 1348 additions and 505 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.3"
version = "0.2.4"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
version = "0.2.3"
version = "0.2.4"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+5 -2
View File
@@ -15,10 +15,11 @@ The script creates `data/db` and `data/files`, builds the project, and starts it
- Real-time collaborative editing over WebSocket.
- Nicknames stored in `localStorage`.
- Change authors shown in history.
- Line numbering enabled by default, with a persistent toggle.
- Line numbering enabled by default, with per-account preferences stored separately for each note or pad.
- Signed-in users with read/write access can save personal compact view, line, font, size, authorship, and color preferences; resource-linked rows are removed with the note, pad, or account.
- Owner color displayed next to each line.
- Image and file uploads to `data/files/pads/<id>_<token>/` or `data/files/notes/<id>_<token>/`.
- Automatic Markdown link insertion after upload.
- Compact attachment aliases are inserted after upload: `[file=name.ext,label]` and `[image=name.ext,alt]`. The file dialog also provides standard Markdown for compatibility.
- Markdown and Mermaid diagram rendering.
- History with snippets, previews, and version restore.
- Alert blocks: `success`, `info`, `warning`, and `danger`.
@@ -150,6 +151,8 @@ RustPad supports two interchangeable attachment backends selected in `.env`:
Public application URLs remain `/f/{token}/{filename}` for both backends. RustPad validates access and streams objects through the API, so the bucket does not need to be public and existing database records do not require migration.
Set `ASSET_CACHE_MAX_AGE_SECONDS=0` or `FILE_CACHE_MAX_AGE_SECONDS=0` to disable browser caching. RustPad then sends `Cache-Control: no-cache, no-store, must-revalidate`; positive values use `public, max-age=<seconds>`.
For the optional Docker Garage service, configure the S3 variables shown in `.env.example`, use strong unique credentials, and run:
```sh
@@ -0,0 +1,25 @@
CREATE TABLE user_editor_preferences (
user_id BIGINT NOT NULL,
pad_id BIGINT NULL,
note_id BIGINT NULL,
authorship_mode VARCHAR(16) NOT NULL DEFAULT 'simple',
colors_enabled BOOLEAN NOT NULL DEFAULT TRUE,
compact_view BOOLEAN NOT NULL DEFAULT TRUE,
editor_line_numbers BOOLEAN NOT NULL DEFAULT TRUE,
preview_line_numbers BOOLEAN NOT NULL DEFAULT FALSE,
line_links BOOLEAN NOT NULL DEFAULT FALSE,
font_family VARCHAR(16) NOT NULL DEFAULT 'mono',
font_size BIGINT NOT NULL DEFAULT 14,
updated_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT fk_user_editor_preferences_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_editor_preferences_pad FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE,
CONSTRAINT fk_user_editor_preferences_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
CONSTRAINT chk_user_editor_preferences_resource CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL)),
UNIQUE KEY uq_user_editor_preferences_pad (user_id, pad_id),
UNIQUE KEY uq_user_editor_preferences_note (user_id, note_id),
INDEX idx_user_editor_preferences_user (user_id),
INDEX idx_user_editor_preferences_pad (pad_id),
INDEX idx_user_editor_preferences_note (note_id)
) ENGINE=InnoDB;
DROP TABLE resource_editor_settings;
@@ -0,0 +1,40 @@
CREATE TABLE resource_editor_settings (
resource_kind VARCHAR(32) NOT NULL,
resource_slug VARCHAR(512) NOT NULL,
authorship_mode VARCHAR(16) NOT NULL DEFAULT 'simple',
colors_enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (resource_kind, resource_slug)
);
INSERT INTO resource_editor_settings (
resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at
)
SELECT 'pad', p.slug, preferences.authorship_mode, preferences.colors_enabled,
CURRENT_TIMESTAMP
FROM user_editor_preferences preferences
JOIN pads p ON p.id = preferences.pad_id
JOIN user_pads ownership
ON ownership.pad_id = p.id
AND ownership.user_id = preferences.user_id
WHERE preferences.pad_id IS NOT NULL
ON DUPLICATE KEY UPDATE resource_slug = VALUES(resource_slug);
INSERT INTO resource_editor_settings (
resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at
)
SELECT 'note', CONCAT(workspace.slug, '/', note.slug),
preferences.authorship_mode, preferences.colors_enabled,
CURRENT_TIMESTAMP
FROM user_editor_preferences preferences
JOIN notes note ON note.id = preferences.note_id
JOIN workspaces workspace ON workspace.id = note.workspace_id
JOIN user_workspaces ownership
ON ownership.workspace_id = workspace.id
AND ownership.user_id = preferences.user_id
WHERE preferences.note_id IS NOT NULL
ON DUPLICATE KEY UPDATE resource_slug = VALUES(resource_slug);
ALTER TABLE user_editor_preferences
DROP COLUMN authorship_mode,
DROP COLUMN colors_enabled;
@@ -0,0 +1,23 @@
CREATE TABLE user_editor_preferences (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
pad_id BIGINT REFERENCES pads(id) ON DELETE CASCADE,
note_id BIGINT REFERENCES notes(id) ON DELETE CASCADE,
authorship_mode TEXT NOT NULL DEFAULT 'simple',
colors_enabled BOOLEAN NOT NULL DEFAULT TRUE,
compact_view BOOLEAN NOT NULL DEFAULT TRUE,
editor_line_numbers BOOLEAN NOT NULL DEFAULT TRUE,
preview_line_numbers BOOLEAN NOT NULL DEFAULT FALSE,
line_links BOOLEAN NOT NULL DEFAULT FALSE,
font_family TEXT NOT NULL DEFAULT 'mono',
font_size BIGINT NOT NULL DEFAULT 14,
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text),
CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL)),
UNIQUE (user_id, pad_id),
UNIQUE (user_id, note_id)
);
CREATE INDEX idx_user_editor_preferences_user ON user_editor_preferences(user_id);
CREATE INDEX idx_user_editor_preferences_pad ON user_editor_preferences(pad_id);
CREATE INDEX idx_user_editor_preferences_note ON user_editor_preferences(note_id);
DROP TABLE resource_editor_settings;
@@ -0,0 +1,40 @@
CREATE TABLE resource_editor_settings (
resource_kind TEXT NOT NULL,
resource_slug TEXT NOT NULL,
authorship_mode TEXT NOT NULL DEFAULT 'simple',
colors_enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (resource_kind, resource_slug)
);
INSERT INTO resource_editor_settings (
resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at
)
SELECT 'pad', p.slug, preferences.authorship_mode, preferences.colors_enabled,
preferences.updated_at::timestamptz
FROM user_editor_preferences preferences
JOIN pads p ON p.id = preferences.pad_id
JOIN user_pads ownership
ON ownership.pad_id = p.id
AND ownership.user_id = preferences.user_id
WHERE preferences.pad_id IS NOT NULL
ON CONFLICT (resource_kind, resource_slug) DO NOTHING;
INSERT INTO resource_editor_settings (
resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at
)
SELECT 'note', workspace.slug || '/' || note.slug,
preferences.authorship_mode, preferences.colors_enabled,
preferences.updated_at::timestamptz
FROM user_editor_preferences preferences
JOIN notes note ON note.id = preferences.note_id
JOIN workspaces workspace ON workspace.id = note.workspace_id
JOIN user_workspaces ownership
ON ownership.workspace_id = workspace.id
AND ownership.user_id = preferences.user_id
WHERE preferences.note_id IS NOT NULL
ON CONFLICT (resource_kind, resource_slug) DO NOTHING;
ALTER TABLE user_editor_preferences
DROP COLUMN authorship_mode,
DROP COLUMN colors_enabled;
@@ -0,0 +1,26 @@
CREATE TABLE user_editor_preferences (
user_id INTEGER NOT NULL,
pad_id INTEGER,
note_id INTEGER,
authorship_mode TEXT NOT NULL DEFAULT 'simple',
colors_enabled INTEGER NOT NULL DEFAULT 1,
compact_view INTEGER NOT NULL DEFAULT 1,
editor_line_numbers INTEGER NOT NULL DEFAULT 1,
preview_line_numbers INTEGER NOT NULL DEFAULT 0,
line_links INTEGER NOT NULL DEFAULT 0,
font_family TEXT NOT NULL DEFAULT 'mono',
font_size INTEGER NOT NULL DEFAULT 14,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE,
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL)),
UNIQUE (user_id, pad_id),
UNIQUE (user_id, note_id)
);
CREATE INDEX idx_user_editor_preferences_user ON user_editor_preferences(user_id);
CREATE INDEX idx_user_editor_preferences_pad ON user_editor_preferences(pad_id);
CREATE INDEX idx_user_editor_preferences_note ON user_editor_preferences(note_id);
DROP TABLE resource_editor_settings;
@@ -0,0 +1,67 @@
CREATE TABLE resource_editor_settings (
resource_kind TEXT NOT NULL,
resource_slug TEXT NOT NULL,
authorship_mode TEXT NOT NULL DEFAULT 'simple',
colors_enabled INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (resource_kind, resource_slug)
);
INSERT OR IGNORE INTO resource_editor_settings (
resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at
)
SELECT 'pad', p.slug, preferences.authorship_mode, preferences.colors_enabled, preferences.updated_at
FROM user_editor_preferences preferences
JOIN pads p ON p.id = preferences.pad_id
JOIN user_pads ownership
ON ownership.pad_id = p.id
AND ownership.user_id = preferences.user_id
WHERE preferences.pad_id IS NOT NULL;
INSERT OR IGNORE INTO resource_editor_settings (
resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at
)
SELECT 'note', workspace.slug || '/' || note.slug,
preferences.authorship_mode, preferences.colors_enabled, preferences.updated_at
FROM user_editor_preferences preferences
JOIN notes note ON note.id = preferences.note_id
JOIN workspaces workspace ON workspace.id = note.workspace_id
JOIN user_workspaces ownership
ON ownership.workspace_id = workspace.id
AND ownership.user_id = preferences.user_id
WHERE preferences.note_id IS NOT NULL;
ALTER TABLE user_editor_preferences RENAME TO user_editor_preferences_legacy;
CREATE TABLE user_editor_preferences (
user_id INTEGER NOT NULL,
pad_id INTEGER,
note_id INTEGER,
compact_view INTEGER NOT NULL DEFAULT 1,
editor_line_numbers INTEGER NOT NULL DEFAULT 1,
preview_line_numbers INTEGER NOT NULL DEFAULT 0,
line_links INTEGER NOT NULL DEFAULT 0,
font_family TEXT NOT NULL DEFAULT 'mono',
font_size INTEGER NOT NULL DEFAULT 14,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE,
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL)),
UNIQUE (user_id, pad_id),
UNIQUE (user_id, note_id)
);
INSERT INTO user_editor_preferences (
user_id, pad_id, note_id, compact_view, editor_line_numbers,
preview_line_numbers, line_links, font_family, font_size, updated_at
)
SELECT user_id, pad_id, note_id, compact_view, editor_line_numbers,
preview_line_numbers, line_links, font_family, font_size, updated_at
FROM user_editor_preferences_legacy;
DROP TABLE user_editor_preferences_legacy;
CREATE INDEX idx_user_editor_preferences_user ON user_editor_preferences(user_id);
CREATE INDEX idx_user_editor_preferences_pad ON user_editor_preferences(pad_id);
CREATE INDEX idx_user_editor_preferences_note ON user_editor_preferences(note_id);
+37 -9
View File
@@ -101,14 +101,37 @@ pub async fn upload_pad_file(
let mime = mime_guess::from_path(&stored)
.first_or_octet_stream()
.to_string();
let cache_control = format!("public, max-age={}", state.file_cache_max_age_seconds);
let cache_control = crate::cache::cache_control(state.file_cache_max_age_seconds);
state
.storage
.put(&key, bytes.clone().into(), &mime, &cache_control)
.await
.map_err(|_| ApiError::internal("Failed to save the file"))?;
db::register_pad_file(&state.db, pad.id, &stored, &url, &mime, bytes.len() as i64).await?;
Ok(Json(serde_json::json!({"name": stored, "url": url})))
Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime})))
}
pub(super) fn content_references_file(content: &str, filename: &str, url: &str) -> bool {
if content.contains(url) {
return true;
}
for marker in ["[file=", "[image=", "[img="] {
let mut remaining = content;
while let Some(index) = remaining.find(marker) {
let after = &remaining[index + marker.len()..];
let end = after
.find(|character| character == ',' || character == ']')
.unwrap_or(after.len());
if after[..end].trim() == filename {
return true;
}
if end >= after.len() {
break;
}
remaining = &after[end + 1..];
}
}
false
}
pub async fn pad_files(
@@ -127,7 +150,7 @@ pub async fn pad_files(
.await?;
let mut files = db::list_pad_files(&state.db, pad.id).await?;
for file in &mut files {
let attached = pad.content.contains(&file.url);
let attached = content_references_file(&pad.content, &file.filename, &file.url);
if attached != file.is_attached {
db::set_pad_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached;
@@ -265,14 +288,14 @@ pub async fn upload_note_file(
let mime = mime_guess::from_path(&stored)
.first_or_octet_stream()
.to_string();
let cache_control = format!("public, max-age={}", state.file_cache_max_age_seconds);
let cache_control = crate::cache::cache_control(state.file_cache_max_age_seconds);
state
.storage
.put(&key, bytes.clone().into(), &mime, &cache_control)
.await
.map_err(|_| ApiError::internal("Failed to save the file"))?;
db::register_note_file(&state.db, note.id, &stored, &url, &mime, bytes.len() as i64).await?;
Ok(Json(serde_json::json!({"name": stored, "url": url})))
Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime})))
}
pub async fn delete_note(
@@ -316,6 +339,12 @@ pub async fn delete_note(
.await
.map_err(|_| ApiError::internal("Failed to delete note files"))?;
}
db::delete_resource_editor_state(
&state.db,
"note",
&format!("{workspace_slug}/{note_slug}"),
)
.await?;
db::delete_note(&state.db, note.id).await?;
Ok(Json(serde_json::json!({"ok": true})))
}
@@ -337,7 +366,7 @@ pub async fn note_files(
.await?;
let mut files = db::list_note_files(&state.db, note.id).await?;
for file in &mut files {
let attached = note.content.contains(&file.url);
let attached = content_references_file(&note.content, &file.filename, &file.url);
if attached != file.is_attached {
db::set_note_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached;
@@ -464,9 +493,8 @@ async fn serve_token_file(
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_str(&format!(
"public, max-age={}",
state.file_cache_max_age_seconds
HeaderValue::from_str(&crate::cache::cache_control(
state.file_cache_max_age_seconds,
))
.expect("valid file cache-control header"),
);
+259 -83
View File
@@ -48,18 +48,99 @@ fn bearer_token(headers: &HeaderMap) -> Option<&str> {
.filter(|value| !value.is_empty())
}
fn user_session_token(headers: &HeaderMap) -> Option<&str> {
headers
.get("x-rustpad-user-token")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| bearer_token(headers))
}
async fn session_user(
state: &SharedState,
headers: &HeaderMap,
) -> Result<Option<crate::auth::User>, ApiError> {
let Some(token) = user_session_token(headers) else {
return Ok(None);
};
crate::auth::user_from_token(state, token)
.await
.map_err(|error| ApiError::forbidden(&error.message))
}
async fn has_write_permission(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
) -> Result<bool, ApiError> {
let bearer = bearer_token(headers);
if token_access_level(state, kind, slug, bearer).await? >= AccessLevel::Write {
return Ok(true);
}
let session = user_session_token(headers);
if session.is_some() && session != bearer {
return Ok(token_access_level(state, kind, slug, session).await? >= AccessLevel::Write);
}
Ok(false)
}
#[derive(Debug, Serialize)]
pub struct PublishResponse {
url: Option<String>,
enabled: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct MarkdownFileReference {
filename: String,
url: String,
mime_type: String,
}
impl From<db::NoteFile> for MarkdownFileReference {
fn from(file: db::NoteFile) -> Self {
Self {
filename: file.filename,
url: file.url,
mime_type: file.mime_type,
}
}
}
pub(crate) async fn markdown_file_references(
state: &SharedState,
pad_id: Option<i64>,
note_id: Option<i64>,
content: Option<&str>,
) -> Result<Vec<MarkdownFileReference>, ApiError> {
let file_rows = if let Some(id) = pad_id {
db::list_pad_files(&state.db, id).await?
} else if let Some(id) = note_id {
db::list_note_files(&state.db, id).await?
} else {
Vec::new()
};
Ok(file_rows
.into_iter()
.filter(|file| {
content
.map(|value| files::content_references_file(value, &file.filename, &file.url))
.unwrap_or(true)
})
.map(Into::into)
.collect())
}
#[derive(Debug, Serialize)]
pub struct PublicPageResponse {
title: String,
content: String,
updated_at: String,
allow_task_updates: bool,
files: Vec<MarkdownFileReference>,
}
#[derive(Debug, Deserialize)]
@@ -173,7 +254,16 @@ pub struct NoteInfo {
note_color: Option<String>,
authorship_mode: String,
colors_enabled: bool,
compact_view: bool,
editor_line_numbers: bool,
preview_line_numbers: bool,
line_links: bool,
font_family: String,
font_size: i64,
personal_editor_settings: bool,
can_save_editor_settings: bool,
can_manage_authorship: bool,
files: Vec<MarkdownFileReference>,
}
#[derive(Debug, Deserialize)]
@@ -183,35 +273,38 @@ pub struct EditorColorRequest {
#[derive(Debug, Deserialize)]
pub struct EditorSettingsRequest {
authorship_mode: String,
colors_enabled: bool,
#[serde(default)]
authorship_mode: Option<String>,
#[serde(default)]
colors_enabled: Option<bool>,
#[serde(default)]
compact_view: Option<bool>,
#[serde(default)]
editor_line_numbers: Option<bool>,
#[serde(default)]
preview_line_numbers: Option<bool>,
#[serde(default)]
line_links: Option<bool>,
#[serde(default)]
font_family: Option<String>,
#[serde(default)]
font_size: Option<i64>,
}
async fn editor_settings(
async fn user_editor_preferences(
state: &SharedState,
kind: &str,
slug: &str,
) -> Result<(String, bool), ApiError> {
let row: Option<(String, i64)> = sqlx::query_as(queries::get(
state.db.kind(),
queries::RESOURCE_EDITOR_SETTINGS_SELECT,
headers: &HeaderMap,
resource: db::EditorPreferenceResource,
) -> Result<(db::EditorPreferences, bool), ApiError> {
let Some(user) = session_user(state, headers).await? else {
return Ok((db::EditorPreferences::default(), false));
};
Ok((
db::load_editor_preferences(&state.db, user.id, resource)
.await?
.unwrap_or_default(),
true,
))
.bind(kind)
.bind(slug)
.fetch_optional(state.db.pool())
.await?;
Ok(row
.map(|(mode, colors)| {
(
if mode == "full" {
"full".into()
} else {
"simple".into()
},
colors != 0,
)
})
.unwrap_or_else(|| ("simple".into(), true)))
}
async fn save_editor_settings(
@@ -221,49 +314,123 @@ async fn save_editor_settings(
permission_slug: &str,
settings_kind: &str,
settings_slug: &str,
resource: db::EditorPreferenceResource,
payload: EditorSettingsRequest,
) -> Result<Json<serde_json::Value>, ApiError> {
let permission = crate::auth::resource_permission(
if !has_write_permission(
state,
headers,
permission_kind,
permission_slug,
)
.await?
{
return Err(ApiError::forbidden(
"Read and write access is required to save editor preferences",
));
}
let user = session_user(state, headers)
.await?
.ok_or_else(|| ApiError::forbidden("Log in to save personal editor preferences"))?;
let wants_personal_update = payload.compact_view.is_some()
|| payload.editor_line_numbers.is_some()
|| payload.preview_line_numbers.is_some()
|| payload.line_links.is_some()
|| payload.font_family.is_some()
|| payload.font_size.is_some();
let wants_global_update = payload.authorship_mode.is_some() || payload.colors_enabled.is_some();
if !wants_personal_update && !wants_global_update {
return Err(ApiError::bad_request("No editor settings were provided"));
}
let can_manage_authorship = if wants_global_update {
crate::auth::is_resource_owner(
state,
permission_kind,
permission_slug,
bearer_token(headers),
user_session_token(headers),
)
.await
.map_err(|e| ApiError::forbidden(&e.message))?;
if permission.as_deref() != Some("rw") {
.unwrap_or(false)
} else {
false
};
if wants_global_update && !can_manage_authorship {
return Err(ApiError::forbidden(
"Read and write access is required to save editor settings",
"Only the resource owner can change authorship settings",
));
}
let mode = match payload.authorship_mode.as_str() {
"simple" => "simple",
"full" | "advanced" => "full",
let preferences = if wants_personal_update {
let mut preferences = db::load_editor_preferences(&state.db, user.id, resource)
.await?
.unwrap_or_default();
if let Some(value) = payload.compact_view {
preferences.compact_view = value;
}
if let Some(value) = payload.editor_line_numbers {
preferences.editor_line_numbers = value;
}
if let Some(value) = payload.preview_line_numbers {
preferences.preview_line_numbers = value;
}
if let Some(value) = payload.line_links {
preferences.line_links = value;
}
if let Some(value) = payload.font_family {
preferences.font_family = match value.as_str() {
"mono" | "system" | "serif" | "arial" | "georgia" => value,
_ => return Err(ApiError::bad_request("Invalid editor font")),
};
}
if let Some(value) = payload.font_size {
if !matches!(value, 14 | 16 | 18 | 20 | 22) {
return Err(ApiError::bad_request("Invalid editor font size"));
}
preferences.font_size = value;
}
Some(preferences)
} else {
None
};
let resource_settings = if wants_global_update {
let mut settings = db::load_resource_editor_settings(
&state.db,
settings_kind,
settings_slug,
)
.await?;
if let Some(mode) = payload.authorship_mode {
settings.authorship_mode = match mode.as_str() {
"simple" => "simple".into(),
"full" | "advanced" => "full".into(),
_ => return Err(ApiError::bad_request("Invalid authorship mode")),
};
let mut tx = state.db.pool().begin().await?;
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_EDITOR_SETTINGS_DELETE,
))
.bind(settings_kind)
.bind(settings_slug)
.execute(&mut *tx)
}
if let Some(value) = payload.colors_enabled {
settings.colors_enabled = value;
}
Some(settings)
} else {
None
};
db::save_editor_configuration(
&state.db,
user.id,
resource,
preferences.as_ref(),
resource_settings
.as_ref()
.map(|settings| (settings_kind, settings_slug, settings)),
)
.await?;
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_EDITOR_SETTINGS_INSERT,
))
.bind(settings_kind)
.bind(settings_slug)
.bind(mode)
.bind(payload.colors_enabled)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(
serde_json::json!({"authorship_mode": mode, "colors_enabled": payload.colors_enabled}),
))
Ok(Json(serde_json::json!({
"preferences": preferences,
"resource_settings": resource_settings,
})))
}
pub async fn create_workspace(
@@ -456,10 +623,7 @@ async fn editor_colors(
kind: &str,
slug: &str,
) -> Result<(Option<String>, Option<String>), ApiError> {
let Some(user) = crate::auth::optional_user(state, headers)
.await
.map_err(|e| ApiError::forbidden(&e.message))?
else {
let Some(user) = session_user(state, headers).await? else {
return Ok((None, None));
};
let global: Option<String> = sqlx::query_scalar(queries::get(
@@ -488,9 +652,8 @@ async fn save_editor_color(
slug: &str,
color: Option<&str>,
) -> Result<Json<serde_json::Value>, ApiError> {
let user = crate::auth::optional_user(state, headers)
.await
.map_err(|e| ApiError::forbidden(&e.message))?
let user = session_user(state, headers)
.await?
.ok_or_else(|| ApiError::forbidden("Log in to save note colors"))?;
let color = clean_editor_color(color)?;
let mut tx = state.db.pool().begin().await?;
@@ -540,18 +703,24 @@ pub async fn note_info(
.ok_or_else(ApiError::not_found_note)?;
let color_slug = format!("{}/{}", workspace_slug, note_slug);
let (global_color, note_color) = editor_colors(&state, &headers, "note", &color_slug).await?;
let (authorship_mode, colors_enabled) = editor_settings(&state, "note", &color_slug).await?;
let can_save_editor_settings = crate::auth::resource_permission(
let (editor_preferences, personal_editor_settings) = user_editor_preferences(
&state,
&headers,
db::EditorPreferenceResource::Note(note.id),
)
.await?;
let resource_editor_settings =
db::load_resource_editor_settings(&state.db, "note", &color_slug).await?;
let can_save_editor_settings = personal_editor_settings
&& has_write_permission(&state, &headers, "workspace", &workspace_slug).await?;
let can_manage_authorship = crate::auth::is_resource_owner(
&state,
"workspace",
&workspace_slug,
bearer_token(&headers),
user_session_token(&headers),
)
.await
.ok()
.flatten()
.as_deref()
== Some("rw");
.unwrap_or(false);
if workspace.is_private == 0
&& !db::note_public_page_disabled(&state.db, note.id).await?
@@ -573,15 +742,7 @@ pub async fn note_info(
created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
can_delete_files: {
let workspace_owner = crate::auth::is_resource_owner(
&state,
"workspace",
&workspace_slug,
bearer_token(&headers),
)
.await
.unwrap_or(false);
let note_owner = crate::auth::optional_user(&state, &headers)
let note_owner = session_user(&state, &headers)
.await
.ok()
.flatten()
@@ -591,13 +752,22 @@ pub async fn note_info(
.map(|creator| creator == user.nickname)
})
.unwrap_or(false);
workspace_owner || note_owner
can_manage_authorship || note_owner
},
global_color,
note_color,
authorship_mode,
colors_enabled,
authorship_mode: resource_editor_settings.authorship_mode,
colors_enabled: resource_editor_settings.colors_enabled,
compact_view: editor_preferences.compact_view,
editor_line_numbers: editor_preferences.editor_line_numbers,
preview_line_numbers: editor_preferences.preview_line_numbers,
line_links: editor_preferences.line_links,
font_family: editor_preferences.font_family,
font_size: editor_preferences.font_size,
personal_editor_settings,
can_save_editor_settings,
can_manage_authorship,
files: markdown_file_references(&state, None, Some(note.id), None).await?,
}))
}
@@ -607,14 +777,20 @@ pub async fn set_note_editor_settings(
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<EditorSettingsRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let settings_slug = format!("{}/{}", workspace_slug, note_slug);
let workspace = db::find_workspace(&state.db, &workspace_slug)
.await?
.ok_or_else(ApiError::not_found_workspace)?;
let note = db::find_note(&state.db, workspace.id, &note_slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
save_editor_settings(
&state,
&headers,
"workspace",
&workspace_slug,
"note",
&settings_slug,
&format!("{workspace_slug}/{note_slug}"),
db::EditorPreferenceResource::Note(note.id),
payload,
)
.await
+68 -18
View File
@@ -38,7 +38,16 @@ pub struct PadInfo {
note_color: Option<String>,
authorship_mode: String,
colors_enabled: bool,
compact_view: bool,
editor_line_numbers: bool,
preview_line_numbers: bool,
line_links: bool,
font_family: String,
font_size: i64,
personal_editor_settings: bool,
can_save_editor_settings: bool,
can_manage_authorship: bool,
files: Vec<MarkdownFileReference>,
}
pub async fn create_pad(
@@ -92,14 +101,24 @@ pub async fn pad_info(
)
.await?;
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
let (authorship_mode, colors_enabled) = editor_settings(&state, "pad", &slug).await?;
let can_save_editor_settings =
crate::auth::resource_permission(&state, "pad", &slug, bearer_token(&headers))
let (editor_preferences, personal_editor_settings) = user_editor_preferences(
&state,
&headers,
db::EditorPreferenceResource::Pad(pad.id),
)
.await?;
let resource_editor_settings =
db::load_resource_editor_settings(&state.db, "pad", &slug).await?;
let can_manage_authorship = crate::auth::is_resource_owner(
&state,
"pad",
&slug,
user_session_token(&headers),
)
.await
.ok()
.flatten()
.as_deref()
== Some("rw");
.unwrap_or(false);
let can_save_editor_settings = personal_editor_settings
&& has_write_permission(&state, &headers, "pad", &slug).await?;
if pad.is_private == 0
&& !db::pad_public_page_disabled(&state.db, pad.id).await?
&& !db::pad_public_page_enabled(&state.db, pad.id).await?
@@ -116,19 +135,21 @@ pub async fn pad_info(
private: pad.is_private != 0,
created_at: db::normalize_timestamp(&pad.created_at),
updated_at: db::normalize_timestamp(&pad.updated_at),
can_delete_files: crate::auth::is_resource_owner(
&state,
"pad",
&slug,
bearer_token(&headers),
)
.await
.unwrap_or(false),
can_delete_files: can_manage_authorship,
global_color,
note_color,
authorship_mode,
colors_enabled,
authorship_mode: resource_editor_settings.authorship_mode,
colors_enabled: resource_editor_settings.colors_enabled,
compact_view: editor_preferences.compact_view,
editor_line_numbers: editor_preferences.editor_line_numbers,
preview_line_numbers: editor_preferences.preview_line_numbers,
line_links: editor_preferences.line_links,
font_family: editor_preferences.font_family,
font_size: editor_preferences.font_size,
personal_editor_settings,
can_save_editor_settings,
can_manage_authorship,
files: markdown_file_references(&state, Some(pad.id), None, None).await?,
}))
}
@@ -138,7 +159,20 @@ pub async fn set_pad_editor_settings(
Path(slug): Path<String>,
Json(payload): Json<EditorSettingsRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
save_editor_settings(&state, &headers, "pad", &slug, "pad", &slug, payload).await
let pad = db::find_pad(&state.db, &slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
save_editor_settings(
&state,
&headers,
"pad",
&slug,
"pad",
&slug,
db::EditorPreferenceResource::Pad(pad.id),
payload,
)
.await
}
pub async fn pad_editor_color(
@@ -382,11 +416,19 @@ pub async fn public_page(
.await?
.ok_or_else(ApiError::not_found_note)?;
ensure_public_page_access(&state, &headers, &page).await?;
let files = markdown_file_references(
&state,
page.pad_id,
page.note_id,
Some(&page.content),
)
.await?;
Ok(Json(PublicPageResponse {
title: page.title,
content: page.content,
updated_at: db::normalize_timestamp(&page.updated_at),
allow_task_updates: page.allow_task_updates,
files,
}))
}
@@ -408,11 +450,19 @@ pub async fn update_public_task(
let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked)
.await?
.ok_or_else(ApiError::not_found_note)?;
let files = markdown_file_references(
&state,
page.pad_id,
page.note_id,
Some(&page.content),
)
.await?;
Ok(Json(PublicPageResponse {
title: page.title,
content: page.content,
updated_at: db::normalize_timestamp(&page.updated_at),
allow_task_updates: page.allow_task_updates,
files,
}))
}
+3 -2
View File
@@ -46,8 +46,9 @@ pub fn router(
}
});
let asset_cache_control =
HeaderValue::from_str(&format!("public, max-age={asset_cache_max_age_seconds}"))
let asset_cache_control = HeaderValue::from_str(&crate::cache::cache_control(
asset_cache_max_age_seconds,
))
.expect("valid asset cache-control header");
Router::new()
+50 -40
View File
@@ -15,6 +15,36 @@ use axum::{
use crate::{assets, db, state::SharedState};
fn render_editor_page(
state: &SharedState,
entrypoint: &str,
resource_kind: &str,
document_title: &str,
parent_title: &str,
parent_url: &str,
parent_class: &str,
protected_resource_label: &str,
extra_shortcuts: &str,
) -> Response {
let html = include_str!("../../static/editor.html")
.replace("__RESOURCE_KIND__", resource_kind)
.replace("__DOCUMENT_TITLE__", &escape_html(document_title))
.replace("__PARENT_TITLE__", &escape_html(parent_title))
.replace("__PARENT_URL__", &escape_html(parent_url))
.replace("__PARENT_CLASS__", parent_class)
.replace("__PROTECTED_RESOURCE_LABEL__", protected_resource_label)
.replace("__EXTRA_SHORTCUTS__", extra_shortcuts);
assets::render_html(
&html,
&state.asset_version,
state.registration_enabled,
state.ldap.is_some(),
&state.frontend_log_level,
state.upload_max_size_bytes,
entrypoint,
)
}
pub(super) async fn health() -> &'static str {
"ok"
}
@@ -46,19 +76,17 @@ pub(super) async fn home(State(state): State<SharedState>) -> Response {
pub(super) async fn pad(State(state): State<SharedState>, Path(slug): Path<String>) -> Response {
match db::find_pad(&state.db, &slug).await {
Ok(Some(pad)) => {
let html = include_str!("../../static/pad.html")
.replace("__PAD_TITLE__", &escape_html(&pad.title));
assets::render_html(
&html,
&state.asset_version,
state.registration_enabled,
state.ldap.is_some(),
&state.frontend_log_level,
state.upload_max_size_bytes,
Ok(Some(pad)) => render_editor_page(
&state,
"pad",
)
}
"pad",
&pad.title,
"RustPad",
"/",
"home-brand",
"note",
"<kbd>Alt+Enter</kbd><span>New line while editing Preview</span><kbd>Esc</kbd><span>Edit raw Markdown of current Preview line</span>",
),
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
@@ -169,35 +197,17 @@ pub(super) async fn note(
};
match db::find_note(&state.db, workspace.id, &note_slug).await {
Ok(Some(note)) => {
let html = include_str!("../../static/note.html")
.replace(
"__NOTE_TITLE__",
&escape_html(if workspace.is_private != 0 {
"Note"
} else {
&note.title
}),
)
.replace(
"__WORKSPACE_TITLE__",
&escape_html(if workspace.is_private != 0 {
"Workspace"
} else {
&workspace.title
}),
)
.replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug));
assets::render_html(
&html,
&state.asset_version,
state.registration_enabled,
state.ldap.is_some(),
&state.frontend_log_level,
state.upload_max_size_bytes,
Ok(Some(note)) => render_editor_page(
&state,
"note",
)
}
"note",
if workspace.is_private != 0 { "Note" } else { &note.title },
if workspace.is_private != 0 { "Workspace" } else { &workspace.title },
&format!("/w/{workspace_slug}"),
"",
"workspace",
"",
),
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
+26
View File
@@ -944,6 +944,22 @@ pub async fn confirm_account_action(
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_COLORS_DELETE_BY_USER,
))
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::EDITOR_PREFERENCES_DELETE_BY_USER,
))
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), queries::AUTH_ANONYMIZE_USER))
.bind(&deleted_nickname)
.bind(normalize(&deleted_nickname))
@@ -1143,6 +1159,13 @@ pub async fn delete_resource(
.await
.map_err(AuthError::database)?;
for note in notes {
crate::db::delete_resource_editor_state(
&state.db,
"note",
&format!("{}/{}", req.slug.trim(), note.slug),
)
.await
.map_err(AuthError::database)?;
let files = crate::db::list_note_files(&state.db, note.id)
.await
.map_err(AuthError::database)?;
@@ -1161,6 +1184,9 @@ pub async fn delete_resource(
queries::USER_DELETE_WORKSPACE
}
"pad" => {
crate::db::delete_resource_editor_state(&state.db, "pad", req.slug.trim())
.await
.map_err(AuthError::database)?;
if let Some(pad) = crate::db::find_pad(&state.db, req.slug.trim())
.await
.map_err(AuthError::database)?
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
pub const DISABLED_CACHE_CONTROL: &str = "no-cache, no-store, must-revalidate";
pub fn cache_control(max_age_seconds: u64) -> String {
if max_age_seconds == 0 {
DISABLED_CACHE_CONTROL.into()
} else {
format!("public, max-age={max_age_seconds}")
}
}
+193
View File
@@ -0,0 +1,193 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
use super::*;
#[derive(Debug, Clone, Serialize)]
pub struct EditorPreferences {
pub compact_view: bool,
pub editor_line_numbers: bool,
pub preview_line_numbers: bool,
pub line_links: bool,
pub font_family: String,
pub font_size: i64,
}
impl Default for EditorPreferences {
fn default() -> Self {
Self {
compact_view: true,
editor_line_numbers: true,
preview_line_numbers: false,
line_links: false,
font_family: "mono".into(),
font_size: 14,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ResourceEditorSettings {
pub authorship_mode: String,
pub colors_enabled: bool,
}
impl Default for ResourceEditorSettings {
fn default() -> Self {
Self {
authorship_mode: "simple".into(),
colors_enabled: true,
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum EditorPreferenceResource {
Pad(i64),
Note(i64),
}
fn preference_select_query(resource: EditorPreferenceResource) -> queries::Query {
match resource {
EditorPreferenceResource::Pad(_) => queries::EDITOR_PREFERENCES_SELECT_PAD,
EditorPreferenceResource::Note(_) => queries::EDITOR_PREFERENCES_SELECT_NOTE,
}
}
fn preference_upsert_query(resource: EditorPreferenceResource) -> queries::Query {
match resource {
EditorPreferenceResource::Pad(_) => queries::EDITOR_PREFERENCES_UPSERT_PAD,
EditorPreferenceResource::Note(_) => queries::EDITOR_PREFERENCES_UPSERT_NOTE,
}
}
fn resource_id(resource: EditorPreferenceResource) -> i64 {
match resource {
EditorPreferenceResource::Pad(id) | EditorPreferenceResource::Note(id) => id,
}
}
pub async fn load_editor_preferences(
pool: &Database,
user_id: i64,
resource: EditorPreferenceResource,
) -> Result<Option<EditorPreferences>, sqlx::Error> {
let Some(row) = sqlx::query(queries::get(
pool.kind(),
preference_select_query(resource),
))
.bind(user_id)
.bind(resource_id(resource))
.fetch_optional(pool.pool())
.await?
else {
return Ok(None);
};
Ok(Some(EditorPreferences {
compact_view: row.try_get::<i64, _>(0)? != 0,
editor_line_numbers: row.try_get::<i64, _>(1)? != 0,
preview_line_numbers: row.try_get::<i64, _>(2)? != 0,
line_links: row.try_get::<i64, _>(3)? != 0,
font_family: crate::row_decode::text(&row, 4)?,
font_size: row.try_get(5)?,
}))
}
pub async fn save_editor_configuration(
pool: &Database,
user_id: i64,
resource: EditorPreferenceResource,
preferences: Option<&EditorPreferences>,
resource_settings: Option<(&str, &str, &ResourceEditorSettings)>,
) -> Result<(), sqlx::Error> {
let mut tx = pool.pool().begin().await?;
if let Some(preferences) = preferences {
sqlx::query(queries::get(
pool.kind(),
preference_upsert_query(resource),
))
.bind(user_id)
.bind(resource_id(resource))
.bind(preferences.compact_view)
.bind(preferences.editor_line_numbers)
.bind(preferences.preview_line_numbers)
.bind(preferences.line_links)
.bind(&preferences.font_family)
.bind(preferences.font_size)
.execute(&mut *tx)
.await?;
}
if let Some((resource_kind, resource_slug, settings)) = resource_settings {
sqlx::query(queries::get(
pool.kind(),
queries::RESOURCE_EDITOR_SETTINGS_UPSERT,
))
.bind(resource_kind)
.bind(resource_slug)
.bind(&settings.authorship_mode)
.bind(settings.colors_enabled)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn load_resource_editor_settings(
pool: &Database,
resource_kind: &str,
resource_slug: &str,
) -> Result<ResourceEditorSettings, sqlx::Error> {
let Some(row) = sqlx::query(queries::get(
pool.kind(),
queries::RESOURCE_EDITOR_SETTINGS_SELECT,
))
.bind(resource_kind)
.bind(resource_slug)
.fetch_optional(pool.pool())
.await?
else {
return Ok(ResourceEditorSettings::default());
};
Ok(ResourceEditorSettings {
authorship_mode: crate::row_decode::text(&row, 0)?,
colors_enabled: row.try_get::<i64, _>(1)? != 0,
})
}
pub async fn delete_resource_editor_state(
pool: &Database,
resource_kind: &str,
resource_slug: &str,
) -> Result<(), sqlx::Error> {
let mut tx = pool.pool().begin().await?;
sqlx::query(queries::get(
pool.kind(),
queries::RESOURCE_COLORS_DELETE_BY_RESOURCE,
))
.bind(resource_kind)
.bind(resource_slug)
.execute(&mut *tx)
.await?;
sqlx::query(queries::get(
pool.kind(),
queries::RESOURCE_EDITOR_SETTINGS_DELETE,
))
.bind(resource_kind)
.bind(resource_slug)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
+2
View File
@@ -18,9 +18,11 @@ use serde::Serialize;
use sqlx::FromRow;
use sqlx::{Any, Row, Transaction, any::AnyRow};
mod editor_preferences;
mod files;
mod public_pages;
pub use editor_preferences::*;
pub use files::*;
pub use public_pages::*;
+1
View File
@@ -11,6 +11,7 @@ mod api;
mod app;
mod assets;
mod auth;
mod cache;
mod config;
mod database;
mod db;
+16 -2
View File
@@ -30,9 +30,16 @@ pub enum Query {
RESOURCE_COLOR_BY_USER,
RESOURCE_COLOR_DELETE,
RESOURCE_COLOR_INSERT,
RESOURCE_COLORS_DELETE_BY_RESOURCE,
RESOURCE_COLORS_DELETE_BY_USER,
RESOURCE_EDITOR_SETTINGS_SELECT,
RESOURCE_EDITOR_SETTINGS_UPSERT,
RESOURCE_EDITOR_SETTINGS_DELETE,
RESOURCE_EDITOR_SETTINGS_INSERT,
EDITOR_PREFERENCES_SELECT_PAD,
EDITOR_PREFERENCES_SELECT_NOTE,
EDITOR_PREFERENCES_UPSERT_PAD,
EDITOR_PREFERENCES_UPSERT_NOTE,
EDITOR_PREFERENCES_DELETE_BY_USER,
AUTH_ACCOUNT_ACTION_BY_TOKEN,
AUTH_CONSUME_ACCOUNT_ACTION,
AUTH_UPDATE_EMAIL,
@@ -173,9 +180,16 @@ pub const AUTH_EDITOR_COLOR_BY_USER: Query = Query::AUTH_EDITOR_COLOR_BY_USER;
pub const RESOURCE_COLOR_BY_USER: Query = Query::RESOURCE_COLOR_BY_USER;
pub const RESOURCE_COLOR_DELETE: Query = Query::RESOURCE_COLOR_DELETE;
pub const RESOURCE_COLOR_INSERT: Query = Query::RESOURCE_COLOR_INSERT;
pub const RESOURCE_COLORS_DELETE_BY_RESOURCE: Query = Query::RESOURCE_COLORS_DELETE_BY_RESOURCE;
pub const RESOURCE_COLORS_DELETE_BY_USER: Query = Query::RESOURCE_COLORS_DELETE_BY_USER;
pub const RESOURCE_EDITOR_SETTINGS_SELECT: Query = Query::RESOURCE_EDITOR_SETTINGS_SELECT;
pub const RESOURCE_EDITOR_SETTINGS_UPSERT: Query = Query::RESOURCE_EDITOR_SETTINGS_UPSERT;
pub const RESOURCE_EDITOR_SETTINGS_DELETE: Query = Query::RESOURCE_EDITOR_SETTINGS_DELETE;
pub const RESOURCE_EDITOR_SETTINGS_INSERT: Query = Query::RESOURCE_EDITOR_SETTINGS_INSERT;
pub const EDITOR_PREFERENCES_SELECT_PAD: Query = Query::EDITOR_PREFERENCES_SELECT_PAD;
pub const EDITOR_PREFERENCES_SELECT_NOTE: Query = Query::EDITOR_PREFERENCES_SELECT_NOTE;
pub const EDITOR_PREFERENCES_UPSERT_PAD: Query = Query::EDITOR_PREFERENCES_UPSERT_PAD;
pub const EDITOR_PREFERENCES_UPSERT_NOTE: Query = Query::EDITOR_PREFERENCES_UPSERT_NOTE;
pub const EDITOR_PREFERENCES_DELETE_BY_USER: Query = Query::EDITOR_PREFERENCES_DELETE_BY_USER;
pub const AUTH_ACCOUNT_ACTION_BY_TOKEN: Query = Query::AUTH_ACCOUNT_ACTION_BY_TOKEN;
pub const AUTH_CONSUME_ACCOUNT_ACTION: Query = Query::AUTH_CONSUME_ACCOUNT_ACTION;
pub const AUTH_UPDATE_EMAIL: Query = Query::AUTH_UPDATE_EMAIL;
+28 -6
View File
@@ -38,17 +38,39 @@ pub fn get(query: Query) -> &'static str {
Query::RESOURCE_COLOR_DELETE => {
r#"DELETE FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
}
Query::RESOURCE_COLOR_INSERT => {
r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"#
}
Query::RESOURCE_COLORS_DELETE_BY_RESOURCE => {
r#"DELETE FROM user_resource_colors WHERE resource_kind = ? AND resource_slug = ?"#
}
Query::RESOURCE_COLORS_DELETE_BY_USER => {
r#"DELETE FROM user_resource_colors WHERE user_id = ?"#
}
Query::RESOURCE_EDITOR_SETTINGS_SELECT => {
r#"SELECT authorship_mode, CASE WHEN colors_enabled THEN 1 ELSE 0 END FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
r#"SELECT CAST(authorship_mode AS CHAR CHARACTER SET utf8mb4), CAST(CASE WHEN colors_enabled THEN 1 ELSE 0 END AS SIGNED) FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
}
Query::RESOURCE_EDITOR_SETTINGS_UPSERT => {
r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE authorship_mode = VALUES(authorship_mode), colors_enabled = VALUES(colors_enabled), updated_at = CURRENT_TIMESTAMP"#
}
Query::RESOURCE_EDITOR_SETTINGS_DELETE => {
r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
}
Query::RESOURCE_EDITOR_SETTINGS_INSERT => {
r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled) VALUES (?, ?, ?, ?)"#
Query::EDITOR_PREFERENCES_SELECT_PAD => {
r#"SELECT CAST(CASE WHEN compact_view THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN editor_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN preview_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN line_links THEN 1 ELSE 0 END AS SIGNED), CAST(font_family AS CHAR CHARACTER SET utf8mb4), font_size FROM user_editor_preferences WHERE user_id = ? AND pad_id = ?"#
}
Query::RESOURCE_COLOR_INSERT => {
r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"#
Query::EDITOR_PREFERENCES_SELECT_NOTE => {
r#"SELECT CAST(CASE WHEN compact_view THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN editor_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN preview_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN line_links THEN 1 ELSE 0 END AS SIGNED), CAST(font_family AS CHAR CHARACTER SET utf8mb4), font_size FROM user_editor_preferences WHERE user_id = ? AND note_id = ?"#
}
Query::EDITOR_PREFERENCES_UPSERT_PAD => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
r#"DELETE FROM user_editor_preferences WHERE user_id = ?"#
}
Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => {
r#"SELECT user_id, action, CAST(payload AS CHAR CHARACTER SET utf8mb4) AS payload, expires_at, used_at FROM account_action_tokens WHERE token = ?"#
@@ -85,7 +107,7 @@ pub fn get(query: Query) -> &'static str {
}
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#,
Query::AUTH_ANONYMIZE_USER => {
r#"UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, password_hash = ?, is_active = 0, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, updated_at = ? WHERE id = ?"#
r#"UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, password_hash = ?, is_active = 0, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, editor_color = NULL, updated_at = ? WHERE id = ?"#
}
Query::AUTH_NICKNAME_BY_ID => r#"SELECT nickname FROM users WHERE id = ?"#,
Query::AUTH_ANONYMIZE_NOTE_CREATORS => {
+27 -5
View File
@@ -38,17 +38,39 @@ pub fn get(query: Query) -> &'static str {
Query::RESOURCE_COLOR_DELETE => {
r#"DELETE FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"#
}
Query::RESOURCE_COLOR_INSERT => {
r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES ($1, $2, $3, $4)"#
}
Query::RESOURCE_COLORS_DELETE_BY_RESOURCE => {
r#"DELETE FROM user_resource_colors WHERE resource_kind = $1 AND resource_slug = $2"#
}
Query::RESOURCE_COLORS_DELETE_BY_USER => {
r#"DELETE FROM user_resource_colors WHERE user_id = $1"#
}
Query::RESOURCE_EDITOR_SETTINGS_SELECT => {
r#"SELECT authorship_mode, (CASE WHEN colors_enabled THEN 1 ELSE 0 END)::BIGINT FROM resource_editor_settings WHERE resource_kind = $1 AND resource_slug = $2"#
}
Query::RESOURCE_EDITOR_SETTINGS_UPSERT => {
r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at) VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP) ON CONFLICT (resource_kind, resource_slug) DO UPDATE SET authorship_mode = EXCLUDED.authorship_mode, colors_enabled = EXCLUDED.colors_enabled, updated_at = CURRENT_TIMESTAMP"#
}
Query::RESOURCE_EDITOR_SETTINGS_DELETE => {
r#"DELETE FROM resource_editor_settings WHERE resource_kind = $1 AND resource_slug = $2"#
}
Query::RESOURCE_EDITOR_SETTINGS_INSERT => {
r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled) VALUES ($1, $2, $3, $4)"#
Query::EDITOR_PREFERENCES_SELECT_PAD => {
r#"SELECT (CASE WHEN compact_view THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN editor_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN preview_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN line_links THEN 1 ELSE 0 END)::BIGINT, font_family, font_size FROM user_editor_preferences WHERE user_id = $1 AND pad_id = $2"#
}
Query::RESOURCE_COLOR_INSERT => {
r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES ($1, $2, $3, $4)"#
Query::EDITOR_PREFERENCES_SELECT_NOTE => {
r#"SELECT (CASE WHEN compact_view THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN editor_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN preview_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN line_links THEN 1 ELSE 0 END)::BIGINT, font_family, font_size FROM user_editor_preferences WHERE user_id = $1 AND note_id = $2"#
}
Query::EDITOR_PREFERENCES_UPSERT_PAD => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, pad_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
}
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES ($1, NULL, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
}
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
r#"DELETE FROM user_editor_preferences WHERE user_id = $1"#
}
Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => {
r#"SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = $1"#
@@ -85,7 +107,7 @@ pub fn get(query: Query) -> &'static str {
}
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = $1"#,
Query::AUTH_ANONYMIZE_USER => {
r#"UPDATE users SET nickname = $1, nickname_key = $2, email = $3, email_key = $4, password_hash = $5, is_active = FALSE, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, updated_at = $6 WHERE id = $7"#
r#"UPDATE users SET nickname = $1, nickname_key = $2, email = $3, email_key = $4, password_hash = $5, is_active = FALSE, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, editor_color = NULL, updated_at = $6 WHERE id = $7"#
}
Query::AUTH_NICKNAME_BY_ID => r#"SELECT nickname FROM users WHERE id = $1"#,
Query::AUTH_ANONYMIZE_NOTE_CREATORS => {
+27 -5
View File
@@ -38,17 +38,39 @@ pub fn get(query: Query) -> &'static str {
Query::RESOURCE_COLOR_DELETE => {
r#"DELETE FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
}
Query::RESOURCE_COLOR_INSERT => {
r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"#
}
Query::RESOURCE_COLORS_DELETE_BY_RESOURCE => {
r#"DELETE FROM user_resource_colors WHERE resource_kind = ? AND resource_slug = ?"#
}
Query::RESOURCE_COLORS_DELETE_BY_USER => {
r#"DELETE FROM user_resource_colors WHERE user_id = ?"#
}
Query::RESOURCE_EDITOR_SETTINGS_SELECT => {
r#"SELECT authorship_mode, CASE WHEN colors_enabled THEN 1 ELSE 0 END FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
}
Query::RESOURCE_EDITOR_SETTINGS_UPSERT => {
r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (resource_kind, resource_slug) DO UPDATE SET authorship_mode = excluded.authorship_mode, colors_enabled = excluded.colors_enabled, updated_at = CURRENT_TIMESTAMP"#
}
Query::RESOURCE_EDITOR_SETTINGS_DELETE => {
r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
}
Query::RESOURCE_EDITOR_SETTINGS_INSERT => {
r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled) VALUES (?, ?, ?, ?)"#
Query::EDITOR_PREFERENCES_SELECT_PAD => {
r#"SELECT CASE WHEN compact_view THEN 1 ELSE 0 END, CASE WHEN editor_line_numbers THEN 1 ELSE 0 END, CASE WHEN preview_line_numbers THEN 1 ELSE 0 END, CASE WHEN line_links THEN 1 ELSE 0 END, font_family, font_size FROM user_editor_preferences WHERE user_id = ? AND pad_id = ?"#
}
Query::RESOURCE_COLOR_INSERT => {
r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"#
Query::EDITOR_PREFERENCES_SELECT_NOTE => {
r#"SELECT CASE WHEN compact_view THEN 1 ELSE 0 END, CASE WHEN editor_line_numbers THEN 1 ELSE 0 END, CASE WHEN preview_line_numbers THEN 1 ELSE 0 END, CASE WHEN line_links THEN 1 ELSE 0 END, font_family, font_size FROM user_editor_preferences WHERE user_id = ? AND note_id = ?"#
}
Query::EDITOR_PREFERENCES_UPSERT_PAD => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, pad_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
r#"DELETE FROM user_editor_preferences WHERE user_id = ?"#
}
Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => {
r#"SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = ?"#
@@ -85,7 +107,7 @@ pub fn get(query: Query) -> &'static str {
}
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#,
Query::AUTH_ANONYMIZE_USER => {
r#"UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, password_hash = ?, is_active = 0, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, updated_at = ? WHERE id = ?"#
r#"UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, password_hash = ?, is_active = 0, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, editor_color = NULL, updated_at = ? WHERE id = ?"#
}
Query::AUTH_NICKNAME_BY_ID => r#"SELECT nickname FROM users WHERE id = ?"#,
Query::AUTH_ANONYMIZE_NOTE_CREATORS => {
+109 -12
View File
@@ -300,6 +300,18 @@ textarea:focus {
min-width: 0;
}
.document-heading--copy {
border-radius: 6px;
cursor: copy;
outline: none;
}
.document-heading--copy:hover,
.document-heading--copy:focus-visible {
background: color-mix(in srgb, var(--accent) 10%, transparent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 10%, transparent);
}
.document-heading h1 {
overflow: hidden;
margin: 0;
@@ -4532,11 +4544,22 @@ dialog::backdrop {
gap: 5px;
max-width: calc(100vw - 20px);
padding: 5px;
isolation: isolate;
border: 1px solid rgba(255, 255, 255, .12);
border-radius: 999px;
background: rgba(17, 21, 28, .88);
background: transparent;
box-shadow: 0 8px 24px rgba(0, 0, 0, .28);
}
.pad-page .mobile-editor-bubble::before {
position: absolute;
z-index: -1;
inset: 0;
border-radius: inherit;
background: rgba(17, 21, 28, .88);
backdrop-filter: blur(10px);
content: "";
pointer-events: none;
}
.mobile-editor-bubble>button,
@@ -5059,6 +5082,12 @@ dialog::backdrop {
flex-wrap: wrap;
}
.authorship-controls button:disabled,
.authorship-controls input:disabled+.switch-control__track {
cursor: not-allowed;
opacity: .55;
}
.switch-control {
display: inline-flex;
align-items: center;
@@ -5107,17 +5136,6 @@ dialog::backdrop {
background: var(--accent);
}
.editor-settings-save {
min-height: 22px;
height: 22px;
padding: 0 8px;
font-size: 11px;
}
.editor-settings-save:disabled {
opacity: .45;
cursor: not-allowed;
}
.share-link-row .share-link-info {
margin-top: 8px;
@@ -5320,6 +5338,7 @@ dialog::backdrop {
width: auto;
}
}
/* Optional links to exact editor lines. */
.line-number-button {
display: block;
@@ -5357,3 +5376,81 @@ dialog::backdrop {
background: color-mix(in srgb, var(--success) 18%, transparent);
color: var(--text);
}
/* Personal editor preferences in the compact mobile action bar. */
@media (max-width: 1499px) {
.mobile-editor-options {
position: relative;
flex: 0 0 auto;
}
.mobile-editor-options>summary {
display: inline-grid;
width: 34px;
height: 34px;
padding: 0;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, .07);
color: var(--text);
cursor: pointer;
list-style: none;
}
.mobile-editor-options>summary::-webkit-details-marker {
display: none;
}
.mobile-editor-options[open]>summary {
background: color-mix(in srgb, var(--accent) 24%, rgba(255, 255, 255, .07));
}
.mobile-editor-options__panel {
position: fixed;
right: 12px;
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
left: auto;
display: grid;
width: min(360px, calc(100vw - 24px));
max-height: calc(100dvh - 88px);
gap: 5px;
padding: 12px;
overflow-y: auto;
border: 1px solid var(--border-strong);
border-radius: 10px;
background: var(--surface-strong, #11161e);
box-shadow: 0 16px 40px rgb(0 0 0 / 45%);
}
.mobile-editor-options__panel>label:not(.mobile-option-check) {
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
align-items: center;
gap: 10px;
color: var(--muted);
font-size: .78rem;
}
.mobile-editor-options__panel select {
width: 100%;
min-height: 34px;
}
.mobile-option-check {
display: flex;
min-height: 24px;
align-items: center;
gap: 7px;
color: var(--text);
font-size: .8rem;
line-height: 1.2;
}
.mobile-option-check input {
width: 17px;
height: 17px;
margin: 0;
}
}
+40 -18
View File
@@ -5,19 +5,20 @@
<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>
<title>__DOCUMENT_TITLE__ · __PARENT_TITLE__</title>
__APP_STYLESHEET__
__APP_IMPORT_MAP__
__APP_ENTRYPOINT__
</head>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__">
<body class="pad-page" data-resource-kind="__RESOURCE_KIND__" data-registration-enabled="__REGISTRATION_ENABLED__">
<header class="app-header">
<div class="app-header__main"><a class="brand home-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 class="app-header__main"><a id="resource-parent-link" class="brand __PARENT_CLASS__"
href="__PARENT_URL__">__PARENT_TITLE__</a><span class="header-divider"></span>
<div id="document-link-copy" class="document-heading document-heading--copy" role="button" tabindex="0"
title="Copy this link" aria-label="Copy this link">
<h1 id="document-title">__DOCUMENT_TITLE__</h1>
<p id="document-url" class="document-url"></p>
</div>
</div>
<span class="header-user-control user-color-control"><button id="current-user" class="user-chip" type="button"
@@ -43,7 +44,8 @@
class="public-task-toggle"
title="Allow the published page to open without the resource password or private access"><input
id="unprotect-public-page" type="checkbox"> Unprotect Page</label></div>
</details><button id="files-button" class="secondary-button">Files</button><button id="history-button"
</details><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>
</div>
@@ -119,14 +121,12 @@
<div class="column-label editor-column-label"><span>Editor</span>
<div class="authorship-controls"><label class="switch-control authorship-colors-switch"
title="Show or hide author coloring"><input id="authorship-colors-toggle"
type="checkbox" checked><span class="switch-control__track"
type="checkbox" checked disabled><span class="switch-control__track"
aria-hidden="true"></span><span id="authorship-colors-label">Colors
on</span></label>
<div class="authorship-mode-control" role="group" aria-label="Authorship display"><button
type="button" data-authorship-mode="simple" class="active">Simple</button><button
type="button" data-authorship-mode="full">Full</button></div><button
id="save-editor-settings" class="secondary-button compact-button editor-settings-save"
type="button">Save</button>
type="button" data-authorship-mode="simple" class="active" disabled>Simple</button><button
type="button" data-authorship-mode="full" disabled>Full</button></div>
</div>
</div>
<div id="participant-badges" class="participant-badges" aria-label="Participants"></div>
@@ -193,8 +193,7 @@
<div class="shortcut-grid">
<kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><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><kbd>Alt+Enter</kbd><span>New line while
editing Preview</span><kbd>Esc</kbd><span>Edit raw Markdown of current Preview line</span>
list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span>__EXTRA_SHORTCUTS__
</div>
</div>
</dialog>
@@ -219,9 +218,9 @@
guest</button><button id="show-register" class="text-button" type="button">Register</button><button
id="show-login" class="text-button" type="button">Log in</button></div>
<section id="auth-panel" class="auth-panel" hidden>
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail / LDAP Username<input
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail / organization login<input
id="auth-email" name="username" type="email" maxlength="320" autocomplete="username"
placeholder="you@example.com or name.second-name"></label><label>Password<input
placeholder="you@example.com"></label><label>Password<input
id="auth-password" name="password" type="password" minlength="8" maxlength="128"
autocomplete="current-password"></label><button id="auth-submit" class="primary-button"
type="submit">Log in and continue</button>
@@ -235,7 +234,7 @@
</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
<h2>Protected __PROTECTED_RESOURCE_LABEL__</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="/">Cancel</a>
@@ -245,6 +244,29 @@
<button id="mobile-bubble-drag" class="mobile-bubble-drag" type="button" title="Move quick actions"
aria-label="Move quick actions">⋮⋮</button>
<button id="mobile-files-button" type="button" title="Files" aria-label="Open files">📎</button>
<details id="mobile-editor-options" class="mobile-editor-options">
<summary title="Editor options" aria-label="Open editor options"></summary>
<div class="mobile-editor-options__panel">
<label>Font<select id="mobile-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="mobile-font-size">
<option value="14">14</option>
<option value="16">16</option>
<option value="18">18</option>
<option value="20">20</option>
<option value="22">22</option>
</select></label>
<label class="mobile-option-check"><input id="mobile-line-numbers-toggle" type="checkbox"> Editor lines</label>
<label class="mobile-option-check"><input id="mobile-preview-line-numbers-toggle" type="checkbox"> Preview lines</label>
<label class="mobile-option-check"><input id="mobile-compact-toggle" type="checkbox"> Compact view</label>
<label class="mobile-option-check"><input id="mobile-line-links-toggle" type="checkbox"> Line links</label>
</div>
</details>
<label id="mobile-color-button" class="mobile-color-button" title="Editor color"
aria-label="Change editor color"><span class="mobile-color-dot" aria-hidden="true"></span><input
id="mobile-color-picker" type="color" aria-label="Change editor color"></label>
+29
View File
@@ -28,6 +28,25 @@ function safeUrl(value) {
}
const emoji = EMOJI_SHORTCODES;
let markdownFiles = new Map();
export function setMarkdownFiles(files) {
markdownFiles = new Map((Array.isArray(files) ? files : [])
.filter(file => file && file.filename && file.url)
.map(file => [String(file.filename), {
url: String(file.url),
mimeType: String(file.mime_type || ""),
}]));
}
export function unresolvedMarkdownFileAliases(value) {
const missing = new Set();
const source = String(value || "").replace(/`[^`]*`/g, "");
for (const match of source.matchAll(/\[(?:file|image|img)=([^,\]\s]+)(?:,[^\]]*)?\]/gi)) {
if (!markdownFiles.has(match[1])) missing.add(match[1]);
}
return [...missing];
}
function inline(value) {
const tokens = [];
@@ -39,6 +58,16 @@ function inline(value) {
let html = escapeHtml(value);
html = html.replace(/`([^`]+)`/g, (_, code) => stash(`<code>${code}</code>`));
html = html.replace(/\[(file|image|img)=([^,\]\s]+)(?:,([^\]]*))?\]/gi, (match, kind, filename, label) => {
const file = markdownFiles.get(filename);
if (!file) return match;
const text = String(label || filename).trim() || filename;
if (kind.toLowerCase() === "file") {
return stash(`<a href="${safeUrl(file.url)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-file-alias="file" data-file-name="${filename}">${text}</a>`);
}
if (!file.mimeType.startsWith("image/")) return match;
return stash(`<img src="${safeUrl(file.url)}" alt="${text}" loading="lazy" decoding="async" referrerpolicy="no-referrer" draggable="false" contenteditable="false" data-file-alias="image" data-file-name="${filename}">`);
});
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" referrerpolicy="no-referrer" draggable="false" contenteditable="false"${titleAttr}>`);
+2 -2
View File
@@ -21,7 +21,7 @@ export function createPadAdapter() {
return {
access: { kind: "pad", key: slug },
addressSelector: "#pad-url",
addressSelector: "#document-url",
title: info => `${info.title} · RustPad`,
loadInfo: headers => api(base, { headers }),
loadColor: headers => api(`${base}/editor-color`, { headers }),
@@ -62,7 +62,7 @@ export function createWorkspaceNoteAdapter() {
return {
access: { kind: "workspace", key: workspaceSlug },
addressSelector: "#note-url",
addressSelector: "#document-url",
title: info => `${info.title} · ${info.workspace_title}`,
loadInfo: headers => api(base, { headers }),
loadColor: headers => api(`${base}/editor-color`, { headers }),
+158 -25
View File
@@ -15,7 +15,7 @@ import { copyText } from "@rustpad/clipboard";
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { bindEmojiPicker } from "@rustpad/emoji-picker";
import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown";
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMarkdownFileAliases } from "@rustpad/markdown";
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
import { bindNoteFiles } from "@rustpad/note-files";
@@ -27,24 +27,69 @@ export function startNoteEditor(adapter) {
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread");
let unreadChat = 0;
const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), saveEditorSettingsButton = document.querySelector("#save-editor-settings"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle");
const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken);
const notePreferenceKey = name => `rustpad:${name}:${adapter.access.kind}:${adapter.access.key}`;
const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`;
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "";
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false;
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
const singlePaneQuery = window.matchMedia("(max-width: 760px)");
let compactView = uiState.view === "preview" ? "preview" : "edit";
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on";
compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off";
lineLinksToggle.checked = localStorage.getItem("rustpad:line-links") === "on";
fontFamily.value = localStorage.getItem("rustpad:font-family") || "mono";
fontSize.value = localStorage.getItem("rustpad:font-size") || "14";
let refreshFilesForAliases = () => { };
let aliasRefreshTimer = 0;
let lastUnresolvedAliasKey = "";
let markdownFileSignature = "";
function updateMarkdownFiles(files, { rerender = false } = {}) {
const normalized = (Array.isArray(files) ? files : []).map(file => ({
filename: String(file?.filename || ""),
url: String(file?.url || ""),
mime_type: String(file?.mime_type || ""),
})).sort((left, right) => left.filename.localeCompare(right.filename));
const nextSignature = JSON.stringify(normalized);
const changed = nextSignature !== markdownFileSignature;
markdownFileSignature = nextSignature;
setMarkdownFiles(normalized);
if (rerender && changed) render();
}
function scheduleAliasFileRefresh(content) {
const key = unresolvedMarkdownFileAliases(content).sort().join("\u0000");
if (!key) {
lastUnresolvedAliasKey = "";
return;
}
if (key === lastUnresolvedAliasKey) return;
lastUnresolvedAliasKey = key;
clearTimeout(aliasRefreshTimer);
aliasRefreshTimer = window.setTimeout(() => refreshFilesForAliases(), 200);
}
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem(notePreferenceKey("line-numbers")) !== "off";
previewLineToggle.checked = localStorage.getItem(notePreferenceKey("preview-line-numbers")) === "on";
compactToggle.checked = localStorage.getItem(notePreferenceKey("compact")) !== "off";
lineLinksToggle.checked = localStorage.getItem(notePreferenceKey("line-links")) === "on";
fontFamily.value = localStorage.getItem(notePreferenceKey("font-family")) || "mono";
fontSize.value = localStorage.getItem(notePreferenceKey("font-size")) || "14";
authorshipColorsToggle.checked = authorshipColorsEnabled;
function syncMobileEditorControls() {
if (mobileFontFamily) mobileFontFamily.value = fontFamily.value;
if (mobileFontSize) mobileFontSize.value = fontSize.value;
if (mobileLineToggle) mobileLineToggle.checked = lineToggle.checked;
if (mobilePreviewLineToggle) mobilePreviewLineToggle.checked = previewLineToggle.checked;
if (mobileCompactToggle) mobileCompactToggle.checked = compactToggle.checked;
if (mobileLineLinksToggle) mobileLineLinksToggle.checked = lineLinksToggle.checked;
}
syncMobileEditorControls();
function updateAuthorshipControls() {
const canManage = info?.can_manage_authorship === true;
authorshipColorsToggle.checked = authorshipColorsEnabled;
authorshipColorsToggle.disabled = !canManage;
authorshipColorsLabel.textContent = authorshipColorsEnabled ? "Colors on" : "Colors off";
document.querySelectorAll("[data-authorship-mode]").forEach(button => button.classList.toggle("active", button.dataset.authorshipMode === authorshipMode));
document.querySelectorAll("[data-authorship-mode]").forEach(button => {
button.classList.toggle("active", button.dataset.authorshipMode === authorshipMode);
button.disabled = !canManage;
});
const controls = document.querySelector(".authorship-controls");
if (controls) controls.title = canManage ? "Global authorship settings" : "Only the owner can change authorship settings";
}
function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h * 31 + c.charCodeAt(0)) % 360; return `hsl(${h} 70% 62%)`; }
function ownerParts(owner) { const raw = String(owner || ""); const split = raw.lastIndexOf("\u001f"); return split < 0 ? { name: raw, color: "" } : { name: raw.slice(0, split), color: raw.slice(split + 1) }; }
@@ -67,7 +112,13 @@ export function startNoteEditor(adapter) {
}, replacement, contentLength);
}
function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); const pickerColor = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; const overridden = Boolean(noteUserColor()); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); currentUser.title = overridden ? "Note color override" : "Global profile color"; userColorPicker.value = pickerColor; if (mobileColorPicker) mobileColorPicker.value = pickerColor; useGlobalColorButton.hidden = !overridden; document.querySelector(".mobile-editor-bubble")?.style.setProperty("--owner", color); }
function sessionHeaders() { const token = accessToken || getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
function sessionHeaders() {
const userToken = getAuthToken();
const resourceToken = accessToken || userToken;
const headers = resourceToken ? { Authorization: `Bearer ${resourceToken}` } : {};
if (userToken && userToken !== resourceToken) headers["X-RustPad-User-Token"] = userToken;
return headers;
}
function accountHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
async function loadNoteInfo() {
info = await adapter.loadInfo(sessionHeaders());
@@ -78,10 +129,19 @@ export function startNoteEditor(adapter) {
} else {
noteColor = readGuestColor();
}
updateMarkdownFiles(info.files || []);
if (info.personal_editor_settings) {
compactToggle.checked = info.compact_view !== false;
lineToggle.checked = info.editor_line_numbers !== false;
previewLineToggle.checked = info.preview_line_numbers === true;
lineLinksToggle.checked = info.line_links === true;
if (["mono", "system", "serif", "arial", "georgia"].includes(info.font_family)) fontFamily.value = info.font_family;
if (["14", "16", "18", "20", "22"].includes(String(info.font_size))) fontSize.value = String(info.font_size);
}
authorshipMode = info.authorship_mode === "full" ? "full" : "simple";
authorshipColorsEnabled = info.colors_enabled !== false;
updateAuthorshipControls();
if (saveEditorSettingsButton) saveEditorSettingsButton.disabled = !info.can_save_editor_settings;
syncMobileEditorControls();
updateCurrentUser(); return info;
}
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } renderGutter(); }
@@ -202,10 +262,17 @@ export function startNoteEditor(adapter) {
if (tag === "code") return { open: "`", close: "`", atomic: null };
if (tag === "sub") return { open: "~", close: "~", atomic: null };
if (tag === "sup" && !current.classList.contains("footnote-ref")) return { open: "^", close: "^", atomic: null };
const fileAlias = current.getAttribute("data-file-alias");
const fileName = current.getAttribute("data-file-name");
if (tag === "a" && fileAlias === "file" && fileName) return { open: `[file=${fileName},`, close: "]", atomic: null };
if (tag === "a") return { open: "[", close: `](${current.getAttribute("href") || "#"})`, atomic: null };
if (tag === "img") {
const src = current.getAttribute("src") || "";
const alt = current.getAttribute("alt") || "";
if (fileAlias === "image" && fileName) {
const safeAlt = alt.replace(/\]/g, ")").replace(/[\r\n]+/g, " ");
return { open: "", close: "", atomic: `[image=${fileName},${safeAlt}]` };
}
const src = current.getAttribute("src") || "";
const title = current.getAttribute("title");
return { open: "", close: "", atomic: `![${alt}](${src}${title ? ` "${title.replace(/"/g, "&quot;")}"` : ""})` };
}
@@ -478,7 +545,7 @@ export function startNoteEditor(adapter) {
const ratio = editorRange > 0 ? editor.scrollTop / editorRange : 0;
preview.scrollTop = ratio * previewRange;
}
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; 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}">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); }
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); scheduleAliasFileRefresh(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; 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}">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); }
function activeView() {
return singlePaneQuery.matches ? compactView : uiState.view;
}
@@ -549,9 +616,11 @@ export function startNoteEditor(adapter) {
const { loadFiles } = bindNoteFiles({
editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files),
endpoints: adapter.fileEndpoints,
onFilesChanged: files => updateMarkdownFiles(files, { rerender: true }),
});
refreshFilesForAliases = () => loadFiles();
function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { resourceUnlocked = true; if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) 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" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key) || getAuthToken(); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
async function showSystemNotFound() {
try {
@@ -579,7 +648,7 @@ export function startNoteEditor(adapter) {
return;
}
accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key);
accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key) || getAuthToken();
await loadNoteInfo();
document.title = adapter.title(info);
publicPageEnabled.checked = Boolean(info.public_page_enabled); publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected); updatePageControls();
@@ -631,7 +700,24 @@ export function startNoteEditor(adapter) {
compactLayoutQuery.addEventListener("change", event => {
if (!event.matches) setHeaderMenuOpen(false);
});
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(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); alignPreviewLineNumbers(preview); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); lineLinksToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-links", lineLinksToggle.checked ? "on" : "off"); renderGutter(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); });
modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); });
lineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-numbers"), lineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); });
previewLineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("preview-line-numbers"), previewLineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); alignPreviewLineNumbers(preview); scheduleEditorSettingsSave({ personal: true }); });
compactToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("compact"), compactToggle.checked ? "on" : "off"); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); });
lineLinksToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-links"), lineLinksToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); });
fontFamily.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-family"), fontFamily.value); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); });
fontSize.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-size"), fontSize.value); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); });
const mirrorMobileControl = (mobileControl, desktopControl) => mobileControl?.addEventListener("change", () => {
if (desktopControl instanceof HTMLInputElement && desktopControl.type === "checkbox") desktopControl.checked = mobileControl.checked;
else desktopControl.value = mobileControl.value;
desktopControl.dispatchEvent(new Event("change", { bubbles: true }));
});
mirrorMobileControl(mobileFontFamily, fontFamily);
mirrorMobileControl(mobileFontSize, fontSize);
mirrorMobileControl(mobileLineToggle, lineToggle);
mirrorMobileControl(mobilePreviewLineToggle, previewLineToggle);
mirrorMobileControl(mobileCompactToggle, compactToggle);
mirrorMobileControl(mobileLineLinksToggle, lineLinksToggle);
document.querySelector("#mobile-files-button")?.addEventListener("click", () => document.querySelector("#files-button")?.click());
const compactBubbleQuery = matchMedia("(max-width: 1499px)");
const roomPopover = roomDetails.querySelector(".room-popover");
@@ -788,27 +874,66 @@ export function startNoteEditor(adapter) {
});
}
document.querySelectorAll("[data-authorship-mode]").forEach(button => button.addEventListener("click", () => {
if (!info?.can_manage_authorship) return;
authorshipMode = button.dataset.authorshipMode === "full" ? "full" : "simple";
updateAuthorshipControls();
syncMobileEditorControls();
renderGutter();
scheduleEditorSettingsSave({ authorship: true });
}));
authorshipColorsToggle?.addEventListener("change", () => {
if (!info?.can_manage_authorship) return;
authorshipColorsEnabled = authorshipColorsToggle.checked;
updateAuthorshipControls();
syncMobileEditorControls();
renderGutter();
scheduleEditorSettingsSave({ authorship: true });
});
saveEditorSettingsButton?.addEventListener("click", async () => {
if (!info?.can_save_editor_settings) return;
saveEditorSettingsButton.disabled = true;
function personalEditorSettingsPayload() {
return {
compact_view: compactToggle.checked,
editor_line_numbers: lineToggle.checked,
preview_line_numbers: previewLineToggle.checked,
line_links: lineLinksToggle.checked,
font_family: fontFamily.value,
font_size: Number(fontSize.value),
};
}
function scheduleEditorSettingsSave({ personal = false, authorship = false } = {}) {
if (personal) pendingPersonalSettingsSave = true;
if (authorship && info?.can_manage_authorship) pendingAuthorshipSettingsSave = true;
if (!info?.can_save_editor_settings || (!pendingPersonalSettingsSave && !pendingAuthorshipSettingsSave)) return;
clearTimeout(editorSettingsSaveTimer);
editorSettingsSaveTimer = window.setTimeout(flushEditorSettingsSave, 250);
}
async function flushEditorSettingsSave() {
if (editorSettingsSaveInFlight || !info?.can_save_editor_settings) return;
const savePersonal = pendingPersonalSettingsSave;
const saveAuthorship = pendingAuthorshipSettingsSave && info.can_manage_authorship;
if (!savePersonal && !saveAuthorship) return;
pendingPersonalSettingsSave = false;
pendingAuthorshipSettingsSave = false;
editorSettingsSaveInFlight = true;
const settings = savePersonal ? personalEditorSettingsPayload() : {};
if (saveAuthorship) {
settings.authorship_mode = authorshipMode;
settings.colors_enabled = authorshipColorsEnabled;
}
try {
await adapter.saveEditorSettings(sessionHeaders(), { authorship_mode: authorshipMode, colors_enabled: authorshipColorsEnabled });
toast("Editor settings saved for everyone");
await adapter.saveEditorSettings(sessionHeaders(), settings);
if (savePersonal) info.personal_editor_settings = true;
} catch (error) {
toast(error.message);
} finally {
saveEditorSettingsButton.disabled = !info?.can_save_editor_settings;
editorSettingsSaveInFlight = false;
if (pendingPersonalSettingsSave || pendingAuthorshipSettingsSave) {
clearTimeout(editorSettingsSaveTimer);
editorSettingsSaveTimer = window.setTimeout(flushEditorSettingsSave, 250);
}
}
}
});
window.addEventListener("popstate", () => { lastRevealedLineHash = ""; uiState = readEditorState(); applyUi(); requestAnimationFrame(revealLinkedLine); });
window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; renderGutter(); requestAnimationFrame(revealLinkedLine); });
window.addEventListener("rustpad:urlchange", updateAddressLabel);
@@ -826,9 +951,17 @@ export function startNoteEditor(adapter) {
toast(error.message);
}
});
document.querySelector("#copy-link").addEventListener("click", async () => {
async function copyCurrentLink() {
try { await copyText(currentShareUrl(uiState)); toast("Link copied"); }
catch (error) { toast(error.message); }
}
document.querySelector("#copy-link").addEventListener("click", copyCurrentLink);
const documentLinkCopy = document.querySelector("#document-link-copy");
documentLinkCopy?.addEventListener("click", copyCurrentLink);
documentLinkCopy?.addEventListener("keydown", event => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
copyCurrentLink();
});
let pendingPreviewFormatRange = null;
+19 -7
View File
@@ -31,7 +31,17 @@ function formatDate(value) {
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("pl-PL");
}
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, toast }) {
function aliasCode(filename, label, mimeType) {
const kind = String(mimeType || "").startsWith("image/") ? "image" : "file";
const safeLabel = String(label || filename).replace(/\]/g, ")").replace(/[\r\n]+/g, " ").trim() || filename;
return `[${kind}=${filename},${safeLabel}]`;
}
function markdownCode(url, label, mimeType) {
return String(mimeType || "").startsWith("image/") ? `![${label}](${url})` : `[${label}](${url})`;
}
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, toast, onFilesChanged = () => {} }) {
const dialog = document.querySelector("#files-dialog");
const list = document.querySelector("#files-list");
const input = document.querySelector("#file-input");
@@ -50,12 +60,14 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
</div>
<div class="file-actions">
<button class="action-button action-button--secondary compact-button" data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="alias" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Alias</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button>
<button class="action-button action-button--primary compact-button" data-add-file-to-note data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Add to note</button>
${canDelete() ? `<button class="action-button action-button--danger compact-button" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>` : ""}
</div>
<div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button class="action-button action-button--primary compact-button" data-copy-generated>Copy</button></div>
</div>`).join("") : '<p class="dialog-copy">No files uploaded.</p>';
onFilesChanged(files);
if (open && !dialog.open) dialog.showModal();
} catch (error) {
if (open) toast(error.message);
@@ -95,8 +107,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
});
if (completed) return;
completed = true;
const fileUrl = safeAppUrl(result.url);
const text = file.type.startsWith("image/") ? `![${file.name}](${fileUrl})` : `[${file.name}](${fileUrl})`;
const text = aliasCode(result.name, file.name, result.mime_type || file.type);
editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
editor.dispatchEvent(new Event("input", { bubbles: true }));
uploadToast.success();
@@ -117,8 +128,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
list.addEventListener("click", async event => {
const addButton = event.target.closest("[data-add-file-to-note]");
if (addButton) {
const relative = safeAppUrl(addButton.dataset.url);
const text = addButton.dataset.mime?.startsWith("image/") ? `![${addButton.dataset.name}](${relative})` : `[${addButton.dataset.name}](${relative})`;
const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime);
editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
editor.dispatchEvent(new Event("input", { bubbles: true }));
toast("Added to note");
@@ -130,9 +140,11 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
const output = panel.querySelector("textarea");
const absolute = new URL(safeAppUrl(showButton.dataset.url), location.origin).href;
let text = absolute;
if (showButton.dataset.showFileCode === "markdown") {
if (showButton.dataset.showFileCode === "alias") {
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime);
} else if (showButton.dataset.showFileCode === "markdown") {
const relative = safeAppUrl(showButton.dataset.url);
text = showButton.dataset.mime?.startsWith("image/") ? `![${showButton.dataset.name}](${relative})` : `[${showButton.dataset.name}](${relative})`;
text = markdownCode(relative, showButton.dataset.name, showButton.dataset.mime);
}
output.value = text; panel.hidden = false; output.focus(); output.select(); return;
}
+2 -2
View File
@@ -12,7 +12,7 @@ installGlobalDiagnostics();
import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown";
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles } from "@rustpad/markdown";
import { toast } from "@rustpad/toast";
const token = location.pathname.split("/").filter(Boolean)[1];
@@ -39,7 +39,7 @@ function scrollToPublicAnchor(hash, behavior = "auto") {
target.scrollIntoView({ behavior, block: "start" });
return true;
}
async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() }); if (passwordDialog.open) passwordDialog.close(); passwordError.textContent = ""; 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); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { if (error.status === 401 || error.status === 403) { passwordError.textContent = error.status === 403 ? "Sign in with an authorized account or enter the resource password." : "Enter the correct password."; if (!passwordDialog.open) passwordDialog.showModal(); passwordInput.focus(); return; } content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } }
async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() }); if (passwordDialog.open) passwordDialog.close(); passwordError.textContent = ""; 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`; setMarkdownFiles(page.files || []); content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { if (error.status === 401 || error.status === 403) { passwordError.textContent = error.status === 403 ? "Sign in with an authorized account or enter the resource password." : "Enter the correct password."; if (!passwordDialog.open) passwordDialog.showModal(); passwordInput.focus(); return; } content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } }
content.addEventListener("click", event => {
const link = event.target.closest('.markdown-toc a[href^="#"]');
if (!link) return;
-259
View File
@@ -1,259 +0,0 @@
<!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>
__APP_STYLESHEET__
__APP_IMPORT_MAP__
__APP_ENTRYPOINT__
</head>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__">
<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>
<span class="header-user-control user-color-control"><button id="current-user" class="user-chip" type="button"
title="Change your color"><span class="user-chip__dot" aria-hidden="true"></span><span
class="user-chip__name"></span></button><input id="user-color-picker" class="user-color-picker"
type="color" aria-label="Override color for this note"><button id="use-global-color"
class="use-global-color" type="button" title="Use global profile color"
aria-label="Use global profile color" hidden>↺</button></span>
<div class="header-navigation">
<button id="header-menu-toggle" class="header-menu-toggle" type="button" aria-expanded="false"
aria-controls="header-actions" aria-label="Open navigation menu">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
<div id="header-actions" class="header-actions"><button id="copy-link" class="secondary-button">Copy
link</button><button id="publish-page" class="secondary-button">Page</button>
<details class="page-settings">
<summary class="secondary-button">Page settings</summary>
<div class="page-settings-menu"><label class="public-task-toggle"
title="Enable or disable the published page"><input id="public-page-enabled"
type="checkbox"> Enable Page</label><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</label><label
class="public-task-toggle"
title="Allow the published page to open without the resource password or private access"><input
id="unprotect-public-page" type="checkbox"> Unprotect Page</label></div>
</details><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>
</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" 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 id="emoji-picker" class="emoji-picker">
<summary title="Insert emoji" aria-label="Insert emoji">😀 Emoji</summary>
<div class="emoji-picker-panel">
<input id="emoji-search" class="emoji-search" type="search" placeholder="Search emoji…"
autocomplete="off" aria-label="Search emoji">
<div id="emoji-categories" class="emoji-categories" aria-label="Emoji categories"></div>
<div id="emoji-grid" class="emoji-grid" role="listbox" aria-label="Emoji"></div>
<p id="emoji-empty" class="emoji-empty" hidden>No emoji found.</p>
</div>
</details>
<details class="markdown-more">
<summary title="Extended Markdown">More</summary>
<div class="markdown-more-menu"><button type="button" data-format="details">Collapsible
section</button><button type="button" data-format="toc">Table of
contents</button><button type="button" data-format="alert-success">Success
alert</button><button type="button" data-format="alert-info">Info
alert</button><button type="button" data-format="alert-warning">Warning
alert</button><button type="button" data-format="alert-danger">Danger
alert</button><button type="button" data-format="inline-code">Inline
code</button><button type="button" data-format="codeblock">Code block</button><button
type="button" data-format="codeblock-lines">Code block with line
numbers</button><button type="button" data-format="mermaid">Mermaid
diagram</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> Editor
lines</label><label class="line-toggle"><input id="preview-line-numbers-toggle" type="checkbox">
Preview lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked>
Compact</label><label class="line-toggle"><input id="line-links-toggle" type="checkbox">
Line links</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-column-label"><span>Editor</span>
<div class="authorship-controls"><label class="switch-control authorship-colors-switch"
title="Show or hide author coloring"><input id="authorship-colors-toggle"
type="checkbox" checked><span class="switch-control__track"
aria-hidden="true"></span><span id="authorship-colors-label">Colors
on</span></label>
<div class="authorship-mode-control" role="group" aria-label="Authorship display"><button
type="button" data-authorship-mode="simple" class="active">Simple</button><button
type="button" data-authorship-mode="full">Full</button></div><button
id="save-editor-settings" class="secondary-button compact-button editor-settings-save"
type="button">Save</button>
</div>
</div>
<div id="participant-badges" class="participant-badges" aria-label="Participants"></div>
<div class="editor-shell">
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
<div id="authorship-layer" class="authorship-layer" 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-connection-block"> · <span class="footer-status status"><span
id="status-dot" class="status__dot"></span><span
id="status-text">Connecting…</span></span> · <span id="socket-latency"
class="footer-socket-latency" title="WebSocket round-trip time">— ms</span></span> ·
<details id="room-details" class="room-details">
<summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread"
hidden></span></summary>
<div class="room-popover">
<section class="room-users"><strong>In this room</strong>
<ul id="room-users"></ul>
</section>
<section class="room-chat">
<div class="room-chat__head"><strong>Room chat</strong><span>Messages disappear after
disconnect</span></div>
<div id="chat-messages" class="chat-messages" aria-live="polite"></div>
<form id="chat-form" class="chat-form"><input id="chat-input" maxlength="1000"
autocomplete="off" placeholder="Write a message…"
aria-label="Chat message"><button type="submit">Send</button></form>
</section>
</div>
</details>
</div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button"
aria-haspopup="dialog">Shortcuts</button> · <span id="access-level"
class="footer-access">Access: checking…</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>
<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+Z</kbd><span>Undo</span><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" autocomplete="on" class="dialog-panel identity-panel">
<h2>What should we call you?</h2>
<p class="dialog-copy">Use a free nickname without an account, or register it to reserve it.</p><input
id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required
placeholder="Name or nickname">
<div class="identity-actions"><button id="guest-continue" class="primary-button" type="submit">Continue as
guest</button><button id="show-register" class="text-button" type="button">Register</button><button
id="show-login" class="text-button" type="button">Log in</button></div>
<section id="auth-panel" class="auth-panel" hidden>
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail / organization login<input
id="auth-email" name="username" type="email" maxlength="320" autocomplete="username"
placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password"
type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button
id="auth-submit" class="primary-button" type="submit">Log in and continue</button>
<div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot
password?</button><button id="auth-back" class="text-button" type="button">Back to
nickname</button><button id="logout-account" class="text-button" type="button">Log out saved
account</button></div>
</section>
<p id="identity-error" class="form-message error" role="alert"></p>
</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
class="dialog-link" href="/">Cancel</a>
</form>
</dialog>
<div id="mobile-editor-bubble" class="mobile-editor-bubble" aria-label="Quick editor actions">
<button id="mobile-bubble-drag" class="mobile-bubble-drag" type="button" title="Move quick actions"
aria-label="Move quick actions">⋮⋮</button>
<button id="mobile-files-button" type="button" title="Files" aria-label="Open files">📎</button>
<label id="mobile-color-button" class="mobile-color-button" title="Editor color"
aria-label="Change editor color"><span class="mobile-color-dot" aria-hidden="true"></span><input
id="mobile-color-picker" type="color" aria-label="Change editor color"></label>
<button id="mobile-chat-button" class="mobile-chat-button" type="button" title="Chat"
aria-label="Open chat">💬<span id="mobile-chat-unread" class="mobile-chat-unread" hidden></span></button>
<span id="mobile-connection-status" class="mobile-connection-status" title="WebSocket status"><span
id="mobile-status-dot" class="status__dot"></span><span
id="mobile-status-text">Connecting…</span></span>
</div>
<div id="toast" class="toast"></div>
</body>
</html>