diff --git a/Cargo.lock b/Cargo.lock index 668a115..be8ce6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.1.22" +version = "0.1.24" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 41c4bde..e6f00aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.1.22" +version = "0.1.24" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/migrations/mysql/0020_public_page_disabled.sql b/migrations/mysql/0020_public_page_disabled.sql new file mode 100644 index 0000000..eef1b99 --- /dev/null +++ b/migrations/mysql/0020_public_page_disabled.sql @@ -0,0 +1,2 @@ +ALTER TABLE pads ADD COLUMN public_page_disabled BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE notes ADD COLUMN public_page_disabled BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/mysql/0021_resource_editor_settings.sql b/migrations/mysql/0021_resource_editor_settings.sql new file mode 100644 index 0000000..ab257f4 --- /dev/null +++ b/migrations/mysql/0021_resource_editor_settings.sql @@ -0,0 +1,8 @@ +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) +); diff --git a/migrations/postgres/0020_public_page_disabled.sql b/migrations/postgres/0020_public_page_disabled.sql new file mode 100644 index 0000000..eef1b99 --- /dev/null +++ b/migrations/postgres/0020_public_page_disabled.sql @@ -0,0 +1,2 @@ +ALTER TABLE pads ADD COLUMN public_page_disabled BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE notes ADD COLUMN public_page_disabled BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/postgres/0021_resource_editor_settings.sql b/migrations/postgres/0021_resource_editor_settings.sql new file mode 100644 index 0000000..ac2b36f --- /dev/null +++ b/migrations/postgres/0021_resource_editor_settings.sql @@ -0,0 +1,8 @@ +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) +); diff --git a/migrations/sqlite/0020_public_page_disabled.sql b/migrations/sqlite/0020_public_page_disabled.sql new file mode 100644 index 0000000..3438929 --- /dev/null +++ b/migrations/sqlite/0020_public_page_disabled.sql @@ -0,0 +1,2 @@ +ALTER TABLE pads ADD COLUMN public_page_disabled INTEGER NOT NULL DEFAULT 0; +ALTER TABLE notes ADD COLUMN public_page_disabled INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/sqlite/0021_resource_editor_settings.sql b/migrations/sqlite/0021_resource_editor_settings.sql new file mode 100644 index 0000000..8a0c7d8 --- /dev/null +++ b/migrations/sqlite/0021_resource_editor_settings.sql @@ -0,0 +1,8 @@ +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) +); diff --git a/src/api/mod.rs b/src/api/mod.rs index de8b3bb..7af3f90 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -41,7 +41,8 @@ fn bearer_token(headers: &HeaderMap) -> Option<&str> { #[derive(Debug, Serialize)] pub struct PublishResponse { - url: String, + url: Option, + enabled: bool, } #[derive(Debug, Serialize)] @@ -83,6 +84,7 @@ pub struct PublishRequest { allow_task_updates: bool, #[serde(default)] unprotect_page: bool, + enabled: Option, } #[derive(Debug, Deserialize)] @@ -153,11 +155,16 @@ pub struct NoteInfo { note_protected: bool, allow_public_task_updates: bool, public_page_unprotected: bool, + public_page_enabled: bool, + private: bool, created_at: String, updated_at: String, can_delete_files: bool, global_color: Option, note_color: Option, + authorship_mode: String, + colors_enabled: bool, + can_save_editor_settings: bool, } #[derive(Debug, Deserialize)] @@ -165,6 +172,44 @@ pub struct EditorColorRequest { color: Option, } +#[derive(Debug, Deserialize)] +pub struct EditorSettingsRequest { + authorship_mode: String, + colors_enabled: bool, +} + +async fn editor_settings(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, + )) + .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( + state: &SharedState, headers: &HeaderMap, permission_kind: &str, permission_slug: &str, + settings_kind: &str, settings_slug: &str, payload: EditorSettingsRequest, +) -> Result, ApiError> { + let permission = crate::auth::resource_permission(state, permission_kind, permission_slug, bearer_token(headers)) + .await.map_err(|e| ApiError::forbidden(&e.message))?; + if permission.as_deref() != Some("rw") { + return Err(ApiError::forbidden("Read and write access is required to save editor settings")); + } + let mode = match payload.authorship_mode.as_str() { "simple" => "simple", "full" | "advanced" => "full", _ => 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).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}))) +} + pub async fn create_workspace( State(state): State, headers: HeaderMap, @@ -439,7 +484,12 @@ 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(&state, "workspace", &workspace_slug, bearer_token(&headers)).await.ok().flatten().as_deref() == Some("rw"); + if workspace.is_private == 0 && !db::note_public_page_disabled(&state.db, note.id).await? && !db::note_public_page_enabled(&state.db, note.id).await? { + db::publish_note(&state.db, note.id).await?; + } Ok(Json(NoteInfo { workspace_slug: workspace.slug, workspace_title: workspace.title, @@ -449,6 +499,8 @@ pub async fn note_info( note_protected: note.protected, allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?, public_page_unprotected: db::note_public_page_unprotected(&state.db, note.id).await?, + public_page_enabled: db::note_public_page_enabled(&state.db, note.id).await?, + private: workspace.is_private != 0, created_at: db::normalize_timestamp(¬e.created_at), updated_at: db::normalize_timestamp(¬e.updated_at), can_delete_files: { @@ -474,9 +526,21 @@ pub async fn note_info( }, global_color, note_color, + authorship_mode, + colors_enabled, + can_save_editor_settings, })) } +pub async fn set_note_editor_settings( + State(state): State, headers: HeaderMap, + Path((workspace_slug, note_slug)): Path<(String, String)>, + Json(payload): Json, +) -> Result, ApiError> { + let settings_slug = format!("{}/{}", workspace_slug, note_slug); + save_editor_settings(&state, &headers, "workspace", &workspace_slug, "note", &settings_slug, payload).await +} + pub async fn history( State(state): State, headers: HeaderMap, diff --git a/src/api/pads_public.rs b/src/api/pads_public.rs index 334bec3..eb3cd96 100644 --- a/src/api/pads_public.rs +++ b/src/api/pads_public.rs @@ -20,11 +20,16 @@ pub struct PadInfo { protected: bool, allow_public_task_updates: bool, public_page_unprotected: bool, + public_page_enabled: bool, + private: bool, created_at: String, updated_at: String, can_delete_files: bool, global_color: Option, note_color: Option, + authorship_mode: String, + colors_enabled: bool, + can_save_editor_settings: bool, } pub async fn create_pad( @@ -78,12 +83,19 @@ 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)).await.ok().flatten().as_deref() == Some("rw"); + 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? { + db::publish_pad(&state.db, pad.id).await?; + } Ok(Json(PadInfo { slug: pad.slug, title: pad.title, protected: pad.password_hash.is_some(), allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?, public_page_unprotected: db::pad_public_page_unprotected(&state.db, pad.id).await?, + public_page_enabled: db::pad_public_page_enabled(&state.db, pad.id).await?, + 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( @@ -96,9 +108,19 @@ pub async fn pad_info( .unwrap_or(false), global_color, note_color, + authorship_mode, + colors_enabled, + can_save_editor_settings, })) } +pub async fn set_pad_editor_settings( + State(state): State, headers: HeaderMap, Path(slug): Path, + Json(payload): Json, +) -> Result, ApiError> { + save_editor_settings(&state, &headers, "pad", &slug, "pad", &slug, payload).await +} + pub async fn pad_editor_color( State(state): State, headers: HeaderMap, @@ -181,11 +203,18 @@ pub async fn publish_pad_page( .await? }; require_write(level)?; + let enabled = payload.enabled.unwrap_or(true); + if !enabled { + db::unpublish_pad(&state.db, pad.id).await?; + db::set_pad_public_page_disabled(&state.db, pad.id, true).await?; + return Ok(Json(PublishResponse { url: None, enabled: false })); + } + db::set_pad_public_page_disabled(&state.db, pad.id, false).await?; let token = db::publish_pad(&state.db, pad.id).await?; db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).await?; db::set_pad_public_page_unprotected(&state.db, pad.id, payload.unprotect_page).await?; Ok(Json(PublishResponse { - url: format!("/s/{token}"), + url: Some(format!("/s/{token}")), enabled: true, })) } @@ -219,11 +248,18 @@ pub async fn publish_note_page( .await? }; require_write(level)?; + let enabled = payload.enabled.unwrap_or(true); + if !enabled { + db::unpublish_note(&state.db, note.id).await?; + db::set_note_public_page_disabled(&state.db, note.id, true).await?; + return Ok(Json(PublishResponse { url: None, enabled: false })); + } + db::set_note_public_page_disabled(&state.db, note.id, false).await?; let token = db::publish_note(&state.db, note.id).await?; db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).await?; db::set_note_public_page_unprotected(&state.db, note.id, payload.unprotect_page).await?; Ok(Json(PublishResponse { - url: format!("/s/{token}"), + url: Some(format!("/s/{token}")), enabled: true, })) } diff --git a/src/app/mod.rs b/src/app/mod.rs index e5c36c9..b956d17 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -118,6 +118,7 @@ pub fn router( "/api/pads/{slug}/editor-color", get(api::pad_editor_color).post(api::set_pad_editor_color), ) + .route("/api/pads/{slug}/editor-settings", post(api::set_pad_editor_settings)) .route("/api/pads/{slug}/publish", post(api::publish_pad_page)) .route("/api/pads/{slug}/restore", post(api::pad_restore)) .route( @@ -146,6 +147,7 @@ pub fn router( "/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-color", get(api::note_editor_color).post(api::set_note_editor_color), ) + .route("/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-settings", post(api::set_note_editor_settings)) .route( "/api/workspaces/{workspace_slug}/notes/{note_slug}/publish", post(api::publish_note_page), diff --git a/src/db/public_pages.rs b/src/db/public_pages.rs index 4a5904f..70ae6a7 100644 --- a/src/db/public_pages.rs +++ b/src/db/public_pages.rs @@ -94,6 +94,28 @@ pub async fn publish_note(pool: &Database, note_id: i64) -> Result Result { + Ok(sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q017)) + .bind(pad_id).fetch_optional(pool.pool()).await?.is_some()) +} + +pub async fn note_public_page_enabled(pool: &Database, note_id: i64) -> Result { + Ok(sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q019)) + .bind(note_id).fetch_optional(pool.pool()).await?.is_some()) +} + +pub async fn unpublish_pad(pool: &Database, pad_id: i64) -> Result<(), sqlx::Error> { + let sql = match pool.kind() { DatabaseKind::Postgres => "DELETE FROM published_pages WHERE pad_id = $1", _ => "DELETE FROM published_pages WHERE pad_id = ?" }; + sqlx::query(sql).bind(pad_id).execute(pool.pool()).await?; + Ok(()) +} + +pub async fn unpublish_note(pool: &Database, note_id: i64) -> Result<(), sqlx::Error> { + let sql = match pool.kind() { DatabaseKind::Postgres => "DELETE FROM published_pages WHERE note_id = $1", _ => "DELETE FROM published_pages WHERE note_id = ?" }; + sqlx::query(sql).bind(note_id).execute(pool.pool()).await?; + Ok(()) +} + pub async fn find_published_page( pool: &Database, token: &str, @@ -342,3 +364,29 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for PostgresPublishedPageRow { }) } } + +pub async fn pad_public_page_disabled(pool: &Database, pad_id: i64) -> Result { + let sql = match pool.kind() { DatabaseKind::Postgres => "SELECT public_page_disabled FROM pads WHERE id = $1", _ => "SELECT public_page_disabled FROM pads WHERE id = ?" }; + if pool.kind() == DatabaseKind::Postgres { return Ok(sqlx::query_scalar::<_, bool>(sql).bind(pad_id).fetch_one(pool.pool()).await?); } + Ok(sqlx::query_scalar::<_, i64>(sql).bind(pad_id).fetch_one(pool.pool()).await? != 0) +} + +pub async fn note_public_page_disabled(pool: &Database, note_id: i64) -> Result { + let sql = match pool.kind() { DatabaseKind::Postgres => "SELECT public_page_disabled FROM notes WHERE id = $1", _ => "SELECT public_page_disabled FROM notes WHERE id = ?" }; + if pool.kind() == DatabaseKind::Postgres { return Ok(sqlx::query_scalar::<_, bool>(sql).bind(note_id).fetch_one(pool.pool()).await?); } + Ok(sqlx::query_scalar::<_, i64>(sql).bind(note_id).fetch_one(pool.pool()).await? != 0) +} + +pub async fn set_pad_public_page_disabled(pool: &Database, pad_id: i64, disabled: bool) -> Result<(), sqlx::Error> { + let sql = match pool.kind() { DatabaseKind::Postgres => "UPDATE pads SET public_page_disabled = $1 WHERE id = $2", _ => "UPDATE pads SET public_page_disabled = ? WHERE id = ?" }; + let mut query = sqlx::query(sql); + query = if pool.kind() == DatabaseKind::Postgres { query.bind(disabled) } else { query.bind(if disabled { 1i64 } else { 0i64 }) }; + query.bind(pad_id).execute(pool.pool()).await?; Ok(()) +} + +pub async fn set_note_public_page_disabled(pool: &Database, note_id: i64, disabled: bool) -> Result<(), sqlx::Error> { + let sql = match pool.kind() { DatabaseKind::Postgres => "UPDATE notes SET public_page_disabled = $1 WHERE id = $2", _ => "UPDATE notes SET public_page_disabled = ? WHERE id = ?" }; + let mut query = sqlx::query(sql); + query = if pool.kind() == DatabaseKind::Postgres { query.bind(disabled) } else { query.bind(if disabled { 1i64 } else { 0i64 }) }; + query.bind(note_id).execute(pool.pool()).await?; Ok(()) +} diff --git a/src/queries/mod.rs b/src/queries/mod.rs index 646e296..356506b 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -21,6 +21,9 @@ pub enum Query { RESOURCE_COLOR_BY_USER, RESOURCE_COLOR_DELETE, RESOURCE_COLOR_INSERT, + RESOURCE_EDITOR_SETTINGS_SELECT, + RESOURCE_EDITOR_SETTINGS_DELETE, + RESOURCE_EDITOR_SETTINGS_INSERT, AUTH_ACCOUNT_ACTION_BY_TOKEN, AUTH_CONSUME_ACCOUNT_ACTION, AUTH_UPDATE_EMAIL, @@ -160,6 +163,9 @@ 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_EDITOR_SETTINGS_SELECT: Query = Query::RESOURCE_EDITOR_SETTINGS_SELECT; +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 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; diff --git a/src/queries/mysql.rs b/src/queries/mysql.rs index 2d9846b..a99fb71 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -15,6 +15,9 @@ pub fn get(query: Query) -> &'static str { Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#, Query::RESOURCE_COLOR_BY_USER => r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#, Query::RESOURCE_COLOR_DELETE => r#"DELETE FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#, + 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_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::RESOURCE_COLOR_INSERT => r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"#, 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 = ?"#, Query::AUTH_CONSUME_ACCOUNT_ACTION => r#"UPDATE account_action_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL"#, diff --git a/src/queries/postgres.rs b/src/queries/postgres.rs index 27b7ead..0ac3046 100644 --- a/src/queries/postgres.rs +++ b/src/queries/postgres.rs @@ -15,6 +15,9 @@ pub fn get(query: Query) -> &'static str { Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = $1"#, Query::RESOURCE_COLOR_BY_USER => r#"SELECT color FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"#, Query::RESOURCE_COLOR_DELETE => r#"DELETE FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"#, + 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_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::RESOURCE_COLOR_INSERT => r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES ($1, $2, $3, $4)"#, Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => r#"SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = $1"#, Query::AUTH_CONSUME_ACCOUNT_ACTION => r#"UPDATE account_action_tokens SET used_at = $1 WHERE token = $2 AND used_at IS NULL"#, diff --git a/src/queries/sqlite.rs b/src/queries/sqlite.rs index b195144..ff83152 100644 --- a/src/queries/sqlite.rs +++ b/src/queries/sqlite.rs @@ -15,6 +15,9 @@ pub fn get(query: Query) -> &'static str { Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#, Query::RESOURCE_COLOR_BY_USER => r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#, Query::RESOURCE_COLOR_DELETE => r#"DELETE FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#, + 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_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::RESOURCE_COLOR_INSERT => r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"#, Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => r#"SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = ?"#, Query::AUTH_CONSUME_ACCOUNT_ACTION => r#"UPDATE account_action_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL"#, diff --git a/static/css/styles.css b/static/css/styles.css index 8d50cc5..49cead2 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -4698,3 +4698,102 @@ dialog::backdrop { width: 100%; min-height: 0; } + +/* Compact Page settings dropdown. */ +.page-settings { position: relative; } +.page-settings > summary { list-style: none; cursor: pointer; } +.page-settings > summary::-webkit-details-marker { display: none; } +.page-settings-menu { position: absolute; z-index: 30; top: calc(100% + 6px); right: 0; display: grid; gap: 4px; min-width: 220px; padding: 8px; border: 1px solid var(--border); border-radius: 9px; background: var(--panel); box-shadow: 0 12px 30px rgba(0,0,0,.28); } +.page-settings-menu .public-task-toggle { min-height: 30px; padding: 5px 7px; border-radius: 6px; } +.page-settings-menu .public-task-toggle:hover { background: color-mix(in srgb, var(--surface-strong, #262b35) 72%, transparent); } +#publish-page:disabled { opacity: .45; cursor: not-allowed; } + +/* Keep the Simple/Full switch inside the editor label frame. */ +.authorship-mode-control { padding: 1px; border-radius: 7px; } +.authorship-mode-control button { min-height: 20px; height: 20px; padding: 0 7px; border-radius: 5px; line-height: 20px; } + +/* Make secondary editor actions read clearly as buttons. */ +.markdown-more > summary, +.pad-page .toolbar-action { display: inline-flex; align-items: center; justify-content: center; min-height: 30px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-strong, #262b35); color: var(--text); font-weight: 600; cursor: pointer; box-shadow: inset 0 1px 0 rgba(255,255,255,.04); } +.markdown-more > summary:hover, +.pad-page .toolbar-action:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); filter: brightness(1.08); } +.markdown-more > summary { list-style: none; } +.markdown-more > summary::-webkit-details-marker { display: none; } + + +/* Final UI fixes: opaque Page settings and per-note authorship controls. */ +.page-settings-menu { + background: #171c24; + opacity: 1; + backdrop-filter: none; +} +.authorship-controls { display: inline-flex; align-items: center; gap: 7px; } +.authorship-color-toggle { display: inline-flex; align-items: center; gap: 5px; min-height: 22px; color: var(--muted); font-size: 11px; cursor: pointer; } +.authorship-color-toggle input { width: 28px; height: 16px; margin: 0; accent-color: var(--accent); } +.authorship-layer { overflow: hidden; padding: 0; } +.authorship-canvas { position: absolute; top: 0; left: 0; box-sizing: border-box; color: transparent; white-space: pre; tab-size: 4; will-change: transform; } +.share-link-row .share-link-info { justify-self: end; width: min(100%, 520px); text-align: right; } +.share-link-row .share-link-inline { width: 100%; text-align: left; } +@media (max-width: 800px) { + .share-link-row .share-link-info { justify-self: stretch; width: 100%; text-align: left; } + .authorship-controls { max-width: 100%; gap: 5px; } +} + +.page-settings-menu .public-task-toggle { background: #171c24; } +.page-settings-menu .public-task-toggle:hover { background: #242b36; } + +/* Shared editor display settings */ +.authorship-controls { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } +.switch-control { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-size: 11px; color: var(--muted); user-select: none; } +.switch-control input { position: absolute; opacity: 0; pointer-events: none; } +.switch-control__track { position: relative; width: 30px; height: 16px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-strong, #262b35); transition: .15s ease; } +.switch-control__track::after { content: ""; position: absolute; width: 10px; height: 10px; left: 2px; top: 2px; border-radius: 50%; background: var(--muted); transition: .15s ease; } +.switch-control input:checked + .switch-control__track { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 28%, var(--surface-strong, #262b35)); } +.switch-control input:checked + .switch-control__track::after { transform: translateX(14px); 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; justify-self: start; text-align: left; } + +.preview-editable--source { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace); + white-space: pre-wrap; +} + + +/* Keep generated individual links below the new-link form. */ +.share-link-list-wrap { + display: grid; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} +.share-link-list-wrap h5 { + margin: 0; + font-size: .86rem; +} +.share-link-list { + width: 100%; +} +.share-link-row { + grid-template-columns: minmax(130px, auto) 100px auto auto; + align-items: end; +} +.share-link-row .share-link-info { + grid-column: 1 / -1; + width: 100%; + justify-self: stretch; + text-align: left; + margin-top: 0; +} +.share-link-row .share-link-inline { + width: 100%; +} +@media (max-width: 800px) { + .share-link-row { + grid-template-columns: 1fr; + } + .share-link-row .share-link-info { + grid-column: 1; + } +} diff --git a/static/js/authorship.js b/static/js/authorship.js index 8167591..58387d0 100644 --- a/static/js/authorship.js +++ b/static/js/authorship.js @@ -175,31 +175,33 @@ export function renderAuthorshipLayer(layer, editor, model, colorFor) { if (!layer) return; const style = getComputedStyle(editor); layer.style.left = `${editor.offsetLeft}px`; - layer.style.paddingTop = style.paddingTop; - layer.style.paddingRight = style.paddingRight; - layer.style.paddingBottom = style.paddingBottom; - layer.style.paddingLeft = style.paddingLeft; - layer.style.fontFamily = style.fontFamily; - layer.style.fontSize = style.fontSize; - layer.style.fontWeight = style.fontWeight; - layer.style.lineHeight = style.lineHeight; - layer.style.letterSpacing = style.letterSpacing; + const canvas = document.createElement("div"); + canvas.className = "authorship-canvas"; + canvas.style.paddingTop = style.paddingTop; + canvas.style.paddingRight = style.paddingRight; + canvas.style.paddingBottom = style.paddingBottom; + canvas.style.paddingLeft = style.paddingLeft; + canvas.style.fontFamily = style.fontFamily; + canvas.style.fontSize = style.fontSize; + canvas.style.fontWeight = style.fontWeight; + canvas.style.lineHeight = style.lineHeight; + canvas.style.letterSpacing = style.letterSpacing; + canvas.style.minWidth = `${editor.scrollWidth}px`; + canvas.style.minHeight = `${editor.scrollHeight}px`; const text = editor.value; - const fragment = document.createDocumentFragment(); let cursor = 0; for (const span of normalize(model?.spans, text.length)) { - if (span.start > cursor) fragment.append(document.createTextNode(text.slice(cursor, span.start))); + if (span.start > cursor) canvas.append(document.createTextNode(text.slice(cursor, span.start))); const mark = document.createElement("span"); mark.className = "authorship-fragment"; mark.style.setProperty("--owner", colorFor(span.owner)); mark.textContent = text.slice(span.start, span.end); mark.title = span.owner.split("\u001f", 1)[0]; - fragment.append(mark); + canvas.append(mark); cursor = span.end; } - if (cursor < text.length) fragment.append(document.createTextNode(text.slice(cursor))); - if (!text.endsWith("\n")) fragment.append(document.createTextNode("\n")); - layer.replaceChildren(fragment); - layer.scrollTop = editor.scrollTop; - layer.scrollLeft = editor.scrollLeft; + if (cursor < text.length) canvas.append(document.createTextNode(text.slice(cursor))); + if (!text.endsWith("\n")) canvas.append(document.createTextNode("\n")); + canvas.style.transform = `translate3d(${-editor.scrollLeft}px, ${-editor.scrollTop}px, 0)`; + layer.replaceChildren(canvas); } diff --git a/static/js/home.js b/static/js/home.js index dd8841c..8f0c81d 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -158,7 +158,7 @@ async function loadResources() { - + `; diff --git a/static/js/note-api.js b/static/js/note-api.js index 1933e94..9f085c7 100644 --- a/static/js/note-api.js +++ b/static/js/note-api.js @@ -17,6 +17,7 @@ export function createPadAdapter() { loadInfo: headers => api(base, { headers }), loadColor: headers => api(`${base}/editor-color`, { headers }), saveColor: (headers, color) => api(`${base}/editor-color`, { method: "POST", headers, body: JSON.stringify({ color }) }), + saveEditorSettings: (headers, settings) => api(`${base}/editor-settings`, { method: "POST", headers, body: JSON.stringify(settings) }), fileEndpoints: { list: `${base}/files`, upload: `${base}/files`, @@ -27,9 +28,9 @@ export function createPadAdapter() { method: "POST", body: JSON.stringify({ kind: "pad", slug, password }), }), - publish: (accessToken, allowTaskUpdates, unprotectPage) => api(`${base}/publish`, { + publish: (accessToken, allowTaskUpdates, unprotectPage, enabled = true) => api(`${base}/publish`, { method: "POST", - body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage }), + body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage, enabled }), }), loadHistory: accessToken => api(`${base}/history`, { method: "POST", @@ -57,6 +58,7 @@ export function createWorkspaceNoteAdapter() { loadInfo: headers => api(base, { headers }), loadColor: headers => api(`${base}/editor-color`, { headers }), saveColor: (headers, color) => api(`${base}/editor-color`, { method: "POST", headers, body: JSON.stringify({ color }) }), + saveEditorSettings: (headers, settings) => api(`${base}/editor-settings`, { method: "POST", headers, body: JSON.stringify(settings) }), fileEndpoints: { list: `${base}/files`, upload: `${base}/files`, @@ -67,9 +69,9 @@ export function createWorkspaceNoteAdapter() { method: "POST", body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }), }), - publish: (accessToken, allowTaskUpdates, unprotectPage) => api(`${base}/publish`, { + publish: (accessToken, allowTaskUpdates, unprotectPage, enabled = true) => api(`${base}/publish`, { method: "POST", - body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage }), + body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage, enabled }), }), loadHistory: accessToken => api(`${base}/history`, { method: "POST", diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 567426d..2d38e97 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -17,9 +17,10 @@ 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"), 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"), useGlobalColorButton = document.querySelector("#use-global-color"); + const compactToggle = document.querySelector("#compact-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"), useGlobalColorButton = document.querySelector("#use-global-color"); const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken); - 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 = ["full", "advanced"].includes(localStorage.getItem("rustpad:authorship-mode")) ? "full" : "simple"; + const notePreferenceKey = name => `rustpad:${name}:${adapter.access.kind}:${adapter.access.key}`; + 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; const compactLayoutQuery = window.matchMedia("(max-width: 1499px)"); 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"; @@ -27,6 +28,12 @@ export function startNoteEditor(adapter) { compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off"; fontFamily.value = localStorage.getItem("rustpad:font-family") || "mono"; fontSize.value = localStorage.getItem("rustpad:font-size") || "14"; + authorshipColorsToggle.checked = authorshipColorsEnabled; + function updateAuthorshipControls() { + authorshipColorsToggle.checked = authorshipColorsEnabled; + authorshipColorsLabel.textContent = authorshipColorsEnabled ? "Colors on" : "Colors off"; + document.querySelectorAll("[data-authorship-mode]").forEach(button => button.classList.toggle("active", button.dataset.authorshipMode === authorshipMode)); + } 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) }; } function ownerName(owner) { return ownerParts(owner).name; } @@ -59,6 +66,10 @@ export function startNoteEditor(adapter) { } else { noteColor = readGuestColor(); } + authorshipMode = info.authorship_mode === "full" ? "full" : "simple"; + authorshipColorsEnabled = info.colors_enabled !== false; + updateAuthorshipControls(); + if (saveEditorSettingsButton) saveEditorSettingsButton.disabled = !info.can_save_editor_settings; 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(); } @@ -95,12 +106,12 @@ export function startNoteEditor(adapter) { const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1); const lines = Array.from({ length: lineCount }); const owners = authorshipOwners(authorship); - const showAuthorship = owners.length > 0; + const showAuthorship = authorshipColorsEnabled && owners.length > 0; const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : []; const full = authorshipMode === "full"; authorshipLayer.hidden = !showAuthorship; ownerLabels.hidden = !full || !showAuthorship; - renderParticipantBadges(owners); + renderParticipantBadges(authorshipColorsEnabled ? owners : []); document.querySelectorAll("[data-authorship-mode]").forEach(button => button.classList.toggle("active", button.dataset.authorshipMode === authorshipMode)); editorWorkspace.dataset.authorshipMode = authorshipMode; const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24; @@ -146,10 +157,10 @@ export function startNoteEditor(adapter) { const title = current.getAttribute("title"); return `![${alt}](${src}${title ? ` "${title.replace(/"/g, """)}"` : ""})`; } - if (tag === "br") return " "; + if (tag === "br") return "\n"; return body; }; - return [...node.childNodes].map(walk).join("").replace(/\n/g, " ").trim(); + return [...node.childNodes].map(walk).join("").replace(/\u00a0/g, " "); } function previewCaretOffset(target) { @@ -219,11 +230,6 @@ export function startNoteEditor(adapter) { editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`); document.body.classList.toggle("compact-editor", compactToggle.checked); document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches); - document.querySelectorAll("[data-authorship-mode]").forEach(button => button.addEventListener("click", () => { - authorshipMode = button.dataset.authorshipMode === "full" ? "full" : "simple"; - localStorage.setItem("rustpad:authorship-mode", authorshipMode); - renderGutter(); - })); document.querySelectorAll("[data-view]").forEach(button => { const active = button.dataset.view === view; button.classList.toggle("active", active); @@ -274,7 +280,7 @@ export function startNoteEditor(adapter) { accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); await loadNoteInfo(); document.title = adapter.title(info); - publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected); + publicPageEnabled.checked = Boolean(info.public_page_enabled); publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected); updatePageControls(); adapter.configureView?.(info); applyUi({ write: true, replace: true }); updateCurrentUser(); @@ -394,9 +400,127 @@ export function startNoteEditor(adapter) { mobileBubbleDrag.addEventListener("pointercancel", end); }); window.addEventListener("resize", () => { if (mobileBubble?.style.left) placeMobileBubble(mobileBubble.getBoundingClientRect()); }); - window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true }); - const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked); - publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await savePublicOptions(); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { publicTaskUpdates.disabled = false; } }); unprotectPublicPage.addEventListener("change", async () => { unprotectPublicPage.disabled = true; try { await savePublicOptions(); toast(unprotectPublicPage.checked ? "Published page is now unprotected" : "Published page protection enabled"); } catch (error) { unprotectPublicPage.checked = !unprotectPublicPage.checked; toast(error.message); } finally { unprotectPublicPage.disabled = false; } }); document.querySelector("#publish-page").addEventListener("click", async () => { try { const result = await savePublicOptions(); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } }); + const cancelledPreviewEdits = new WeakSet(); + function commitPreviewEdit(target, { focusNextLine = false } = {}) { + const lineIndex = Number(target.dataset.sourceLine) - 1; + if (lineIndex < 0) return; + const value = markdownFromPreview(target); + const lines = editor.value.split("\n"); + if (target.dataset.rawSourceEdit === "true") { + if (value === lines[lineIndex]) return; + lines[lineIndex] = value.replace(/\n/g, ""); + editor.value = lines.join("\n"); + editor.dispatchEvent(new Event("input", { bubbles: true })); + return; + } + if (!focusNextLine && value === target.dataset.originalValue) return; + if (focusNextLine) cancelledPreviewEdits.add(target); + if (target.dataset.tableCell !== undefined) { + lines[lineIndex] = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value.replace(/\n/g, " ")); + if (focusNextLine) lines.splice(lineIndex + 1, 0, ""); + } else { + const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; + const editedLines = value.split("\n"); + const replacements = editedLines.map((part, index) => `${index === 0 ? prefix : ""}${part}${index === editedLines.length - 1 ? suffix : ""}`); + lines.splice(lineIndex, 1, ...replacements); + } + editor.value = lines.join("\n"); + editor.dispatchEvent(new Event("input", { bubbles: true })); + if (focusNextLine) { + const nextLine = lineIndex + Math.max(2, value.split("\n").length); + const next = preview.querySelector(`[data-source-line="${nextLine}"].preview-editable`); + next?.focus(); + if (next) placePreviewCaret(next, 0); + } + } + function editRawPreviewLine(target) { + const lineIndex = Number(target.dataset.sourceLine) - 1; + const lines = editor.value.split("\n"); + if (lineIndex < 0 || lineIndex >= lines.length) return; + const caretOffset = Math.min(previewCaretOffset(target), lines[lineIndex].length); + target.dataset.rawSourceEdit = "true"; + target.dataset.originalValue = lines[lineIndex]; + target.textContent = lines[lineIndex]; + target.classList.add("preview-editable--source"); + target.focus({ preventScroll: true }); + placePreviewCaret(target, caretOffset); + } + + function insertPreviewLineBreak(target) { + const lineIndex = Number(target.dataset.sourceLine) - 1; + if (lineIndex < 0) return; + const lines = editor.value.split("\n"); + const value = markdownFromPreview(target); + let insertedLineIndex; + if (target.dataset.tableCell !== undefined) { + lines[lineIndex] = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value.replace(/\n/g, " ")); + insertedLineIndex = lineIndex + 1; + lines.splice(insertedLineIndex, 0, ""); + } else { + const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; + const editedLines = value.split("\n"); + const replacements = editedLines.map((part, index) => `${index === 0 ? prefix : ""}${part}${index === editedLines.length - 1 ? suffix : ""}`); + insertedLineIndex = lineIndex + replacements.length; + lines.splice(lineIndex, 1, ...replacements, ""); + } + cancelledPreviewEdits.add(target); + editor.value = lines.join("\n"); + editor.dispatchEvent(new Event("input", { bubbles: true })); + const sourceLine = insertedLineIndex + 1; + const restoreFocus = () => { + const next = preview.querySelector(`[data-source-line="${sourceLine}"].preview-editable`); + if (!next) return; + next.focus({ preventScroll: true }); + placePreviewCaret(next, 0); + }; + restoreFocus(); + queueMicrotask(() => { + const active = document.activeElement; + if (!active || active === document.body || !preview.contains(active)) restoreFocus(); + }); + } + document.querySelectorAll("[data-authorship-mode]").forEach(button => button.addEventListener("click", () => { + authorshipMode = button.dataset.authorshipMode === "full" ? "full" : "simple"; + updateAuthorshipControls(); + renderGutter(); + })); + authorshipColorsToggle?.addEventListener("change", () => { + authorshipColorsEnabled = authorshipColorsToggle.checked; + updateAuthorshipControls(); + renderGutter(); + }); + saveEditorSettingsButton?.addEventListener("click", async () => { + if (!info?.can_save_editor_settings) return; + saveEditorSettingsButton.disabled = true; + try { + await adapter.saveEditorSettings(sessionHeaders(), { authorship_mode: authorshipMode, colors_enabled: authorshipColorsEnabled }); + toast("Editor settings saved for everyone"); + } catch (error) { + toast(error.message); + } finally { + saveEditorSettingsButton.disabled = !info?.can_save_editor_settings; + } + }); + window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("focusin", event => { const target = event.target.closest(".preview-editable"); if (!target) return; target.dataset.originalHtml = target.innerHTML; target.dataset.originalValue = markdownFromPreview(target); }); preview.addEventListener("beforeinput", event => { if (!event.target.closest(".preview-editable")) return; if (event.inputType === "insertParagraph" || event.inputType === "insertLineBreak") event.preventDefault(); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); if (target.dataset.rawSourceEdit === "true") { cancelledPreviewEdits.add(target); render(); } else editRawPreviewLine(target); return; } if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); if (event.altKey) insertPreviewLineBreak(target); else target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (cancelledPreviewEdits.has(target)) { cancelledPreviewEdits.delete(target); return; } commitPreviewEdit(target); }, { capture: true }); + const publishPageButton = document.querySelector("#publish-page"); + function updatePageControls() { + const enabled = publicPageEnabled.checked; + publishPageButton.disabled = !enabled; + publicTaskUpdates.disabled = !enabled; + unprotectPublicPage.disabled = !enabled; + } + const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked, publicPageEnabled.checked); + publicPageEnabled.addEventListener("change", async () => { + const previous = !publicPageEnabled.checked; + updatePageControls(); + publicPageEnabled.disabled = true; + try { await savePublicOptions(); toast(publicPageEnabled.checked ? "Page enabled" : "Page disabled"); } + catch (error) { publicPageEnabled.checked = previous; updatePageControls(); toast(error.message); } + finally { publicPageEnabled.disabled = false; } + }); + publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await savePublicOptions(); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { updatePageControls(); } }); + unprotectPublicPage.addEventListener("change", async () => { unprotectPublicPage.disabled = true; try { await savePublicOptions(); toast(unprotectPublicPage.checked ? "Published page is now unprotected" : "Published page protection enabled"); } catch (error) { unprotectPublicPage.checked = !unprotectPublicPage.checked; toast(error.message); } finally { updatePageControls(); } }); + publishPageButton.addEventListener("click", async () => { if (!publicPageEnabled.checked) return; try { const result = await savePublicOptions(); if (!result.url) throw new Error("Page is disabled"); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } }); roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } else { roomDetails.classList.remove("is-mobile-open"); } }); document.addEventListener("visibilitychange", () => { if (!document.hidden && roomDetails.open) clearUnread(); }); chatForm.addEventListener("submit", event => { event.preventDefault(); const text = chatInput.value.trim(); if (!text || !socket) return; socket.chat(text); chatInput.value = ""; chatInput.focus(); }); diff --git a/static/note.html b/static/note.html index 2d47b66..fb5e292 100644 --- a/static/note.html +++ b/static/note.html @@ -30,10 +30,7 @@
Page settings
@@ -104,7 +101,7 @@
-
Editor
+
Editor