diff --git a/.dockerignore b/.dockerignore index 72c09d7..f7b7195 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,3 +10,4 @@ rustpad.db-wal README.md Dockerfile* docker-compose*.yml +migrate/ \ No newline at end of file diff --git a/.env.example b/.env.example index 5949e1a..8e2a6cc 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,10 @@ RUST_LOG=rustpad=info,tower_http=warn # Maximum upload size UPLOAD_MAX_SIZE_MB=20 +# Browser cache lifetime in seconds +ASSET_CACHE_MAX_AGE_SECONDS=600 +FILE_CACHE_MAX_AGE_SECONDS=300 + # Optional PostgreSQL container configuration POSTGRES_DB=rustpad POSTGRES_USER=rustpad diff --git a/.gitignore b/.gitignore index 423b229..9dc9d2f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ data/db/.db* data/db/*/* data/files/* *.zip +venv +.venv +migrate/etherpad-dry-run-report.json \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 3b45b22..08640c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1408,7 +1408,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.0.4" +version = "0.0.8" dependencies = [ "argon2", "axum", diff --git a/Cargo.toml b/Cargo.toml index 08199f3..dcdbbda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.0.8" +version = "0.0.9" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/migrate/etherpad_dry_run.py b/migrate/etherpad_dry_run.py new file mode 100644 index 0000000..5ff2cea --- /dev/null +++ b/migrate/etherpad_dry_run.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +""" +Etherpad MySQL dry-run analyzer. + +Read-only: +- connects to MySQL, +- inspects the Etherpad `store` table, +- counts key prefixes, +- enumerates pads, +- searches for ep_mypads-related metadata, +- writes a JSON report, +- performs no INSERT/UPDATE/DELETE operations. + +Dependency: + pip install mysql-connector-python +""" + +from __future__ import annotations + +import argparse +import json +import logging +import re +import sys +from collections import Counter +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +import mysql.connector +from mysql.connector import Error as MySQLError + + +LOG = logging.getLogger("etherpad-dry-run") + +PAD_BASE_RE = re.compile(r"^pad:(.+)$") +PAD_CHILD_MARKERS = ( + ":revs:", + ":chat:", + ":readonly:", +) + + +@dataclass +class PadInfo: + pad_id: str + head: int | None + text_preview: str | None + has_mypads_reference: bool + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Read-only analysis of an Etherpad MySQL database." + ) + parser.add_argument("--host", required=True, help="MySQL host") + parser.add_argument("--port", type=int, default=3306, help="MySQL port") + parser.add_argument("--user", required=True, help="MySQL user") + parser.add_argument("--password", required=True, help="MySQL password") + parser.add_argument("--database", required=True, help="MySQL database name") + parser.add_argument("--table", default="store", help="Etherpad store table") + parser.add_argument( + "--output", + default="etherpad-dry-run-report.json", + help="Path to the generated JSON report.", + ) + parser.add_argument( + "--sample-limit", + type=int, + default=30, + help="Maximum number of ep_mypads-related records included in the report.", + ) + parser.add_argument( + "--pad-limit", + type=int, + default=0, + help="Maximum number of pads to inspect; 0 means all.", + ) + parser.add_argument("-v", "--verbose", action="store_true") + return parser.parse_args() + + +def validate_identifier(identifier: str, label: str) -> str: + if not re.fullmatch(r"[A-Za-z0-9_]+", identifier): + raise ValueError(f"Unsafe {label}: {identifier!r}") + return identifier + + +def decode_json(value: str) -> Any: + try: + return json.loads(value) + except (TypeError, json.JSONDecodeError): + return None + + +def extract_pad_text(value: str) -> tuple[int | None, str | None]: + """ + Extracts only the current atext preview already present in pad:. + It does not reconstruct historical changesets. + """ + obj = decode_json(value) + if not isinstance(obj, dict): + return None, None + + head = obj.get("head") + if not isinstance(head, int): + head = None + + text: str | None = None + atext = obj.get("atext") + if isinstance(atext, dict) and isinstance(atext.get("text"), str): + text = atext["text"] + elif isinstance(obj.get("text"), str): + text = obj["text"] + + if text is not None: + text = text.replace("\r", "") + text = text[:300] + + return head, text + + +def query_all(cursor, sql: str, params: tuple[Any, ...] = ()) -> list[tuple]: + cursor.execute(sql, params) + return list(cursor.fetchall()) + + +def main() -> int: + args = parse_args() + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(levelname)s: %(message)s", + ) + + try: + table = validate_identifier(args.table, "table name") + database = validate_identifier(args.database, "database name") + except ValueError as exc: + LOG.error("%s", exc) + return 2 + + connection = None + try: + LOG.info("Connecting to %s:%s/%s", args.host, args.port, database) + connection = mysql.connector.connect( + host=args.host, + port=args.port, + user=args.user, + password=args.password or "", + database=database, + charset="utf8mb4", + use_unicode=True, + autocommit=False, + connection_timeout=10, + ) + + # Explicit read-only transaction. No write statements are issued. + connection.start_transaction(readonly=True, consistent_snapshot=True) + cursor = connection.cursor() + + cursor.execute( + """ + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = %s AND table_name = %s + """, + (database, table), + ) + if cursor.fetchone()[0] != 1: + LOG.error("Table `%s`.`%s` does not exist", database, table) + return 3 + + quoted_table = f"`{table}`" + + LOG.info("Reading key statistics") + total_records = query_all(cursor, f"SELECT COUNT(*) FROM {quoted_table}")[0][0] + + prefix_rows = query_all( + cursor, + f""" + SELECT SUBSTRING_INDEX(`key`, ':', 1) AS prefix, COUNT(*) AS amount + FROM {quoted_table} + GROUP BY prefix + ORDER BY amount DESC, prefix ASC + """, + ) + prefix_counts = {str(prefix): int(amount) for prefix, amount in prefix_rows} + + LOG.info("Finding base pad records") + pad_rows = query_all( + cursor, + f""" + SELECT `key`, `value` + FROM {quoted_table} + WHERE `key` LIKE 'pad:%%' + ORDER BY `key` + """, + ) + + base_pad_rows: list[tuple[str, str]] = [] + for key, value in pad_rows: + if any(marker in key for marker in PAD_CHILD_MARKERS): + continue + match = PAD_BASE_RE.match(key) + if match: + base_pad_rows.append((key, value)) + + if args.pad_limit > 0: + base_pad_rows = base_pad_rows[: args.pad_limit] + + LOG.info("Searching for ep_mypads metadata") + mypads_rows = query_all( + cursor, + f""" + SELECT `key`, `value` + FROM {quoted_table} + WHERE LOWER(`key`) LIKE '%%mypads%%' + OR LOWER(`key`) LIKE '%%folder%%' + OR LOWER(`key`) LIKE '%%workspace%%' + OR LOWER(`key`) LIKE '%%userpads%%' + OR LOWER(`value`) LIKE '%%mypads%%' + ORDER BY `key` + LIMIT %s + """, + (args.sample_limit,), + ) + + mypads_blob = "\n".join( + f"{key}\n{value}" for key, value in mypads_rows + ).lower() + + pads: list[PadInfo] = [] + for key, value in base_pad_rows: + pad_id = key[4:] + head, preview = extract_pad_text(value) + pads.append( + PadInfo( + pad_id=pad_id, + head=head, + text_preview=preview, + has_mypads_reference=pad_id.lower() in mypads_blob, + ) + ) + + child_type_counts = Counter() + for key, _ in pad_rows: + if ":revs:" in key: + child_type_counts["revisions"] += 1 + elif ":chat:" in key: + child_type_counts["chat_messages"] += 1 + elif ":readonly:" in key: + child_type_counts["readonly_mappings"] += 1 + + likely_workspace_pads = [p.pad_id for p in pads if p.has_mypads_reference] + likely_normal_pads = [p.pad_id for p in pads if not p.has_mypads_reference] + + report = { + "mode": "dry-run", + "read_only": True, + "source": { + "host": args.host, + "port": args.port, + "database": database, + "table": table, + }, + "summary": { + "total_store_records": total_records, + "base_pads_inspected": len(pads), + "likely_workspace_pads": len(likely_workspace_pads), + "likely_normal_pads": len(likely_normal_pads), + "mypads_metadata_samples": len(mypads_rows), + }, + "prefix_counts": prefix_counts, + "etherpad_child_record_counts": dict(child_type_counts), + "classification_warning": ( + "Workspace classification is heuristic. It only checks whether a pad ID " + "appears in sampled ep_mypads-related records. No data is written." + ), + "likely_workspace_pad_ids": likely_workspace_pads, + "likely_normal_pad_ids": likely_normal_pads, + "pads": [asdict(p) for p in pads], + "mypads_metadata_samples": [ + { + "key": key, + "decoded_json": decode_json(value), + "raw_preview": value[:2000], + } + for key, value in mypads_rows + ], + } + + output = Path(args.output) + output.write_text( + json.dumps(report, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + connection.rollback() + + print() + print("DRY-RUN SUMMARY") + print(f"Store records: {total_records}") + print(f"Base pads inspected: {len(pads)}") + print(f"Likely workspace pads: {len(likely_workspace_pads)}") + print(f"Likely normal pads: {len(likely_normal_pads)}") + print(f"MyPads metadata samples: {len(mypads_rows)}") + print(f"Report: {output.resolve()}") + print() + print("No database changes were made.") + return 0 + + except MySQLError as exc: + LOG.error("MySQL error: %s", exc) + if connection is not None: + connection.rollback() + return 4 + except OSError as exc: + LOG.error("File error: %s", exc) + if connection is not None: + connection.rollback() + return 5 + finally: + if connection is not None and connection.is_connected(): + connection.close() + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/migrate/requirements.txt b/migrate/requirements.txt new file mode 100644 index 0000000..06498d9 --- /dev/null +++ b/migrate/requirements.txt @@ -0,0 +1 @@ +mysql-connector-python>=9.0,<10.0 diff --git a/src/api.rs b/src/api.rs index 84e2a73..3418742 100644 --- a/src/api.rs +++ b/src/api.rs @@ -12,7 +12,7 @@ use slug::slugify; use crate::{ db, queries, - state::{NoteUpdate, SharedState}, + state::{NoteUpdate, RoomEvent, SharedState}, }; const MAX_NAME_LENGTH: usize = 80; @@ -282,7 +282,7 @@ pub async fn restore( author: Some("restore".into()), owner_map: "[]".into(), }; - let _ = state.note_channel(&workspace_slug, ¬e_slug).await.send(update); + let _ = state.note_channel(&workspace_slug, ¬e_slug).await.send(RoomEvent::Document(update)); Ok(Json(serde_json::json!({"ok": true}))) } @@ -553,7 +553,7 @@ pub async fn pad_restore( author: Some("restore".into()), owner_map, }; - let _ = state.pad_channel(&slug).await.send(update); + let _ = state.pad_channel(&slug).await.send(RoomEvent::Document(update)); Ok(Json(serde_json::json!({"ok": true}))) } @@ -800,7 +800,11 @@ async fn serve_token_file(state: &SharedState, token: &str, filename: &str) -> R HeaderValue::from_str(mime.as_ref()).unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")), ); response.headers_mut().insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); - response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=600")); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_str(&format!("public, max-age={}", state.file_cache_max_age_seconds)) + .expect("valid file cache-control header"), + ); Ok(response) } diff --git a/src/app.rs b/src/app.rs index 355c35a..7f446fd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,7 @@ use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace:: use crate::{api, auth, db, state::SharedState, websocket}; use std::convert::Infallible; -pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize) -> Router { +pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize, asset_cache_max_age_seconds: u64) -> Router { let asset_version = state.asset_version.clone(); let asset_not_found = service_fn(move |_request| { let asset_version = asset_version.clone(); @@ -28,6 +28,9 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize } }); + let asset_cache_control = HeaderValue::from_str(&format!("public, max-age={asset_cache_max_age_seconds}")) + .expect("valid asset cache-control header"); + Router::new() .route("/", get(home)) .route("/p/{slug}", get(pad)) @@ -95,7 +98,7 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize ServiceBuilder::new() .layer(SetResponseHeaderLayer::overriding( header::CACHE_CONTROL, - HeaderValue::from_static("public, max-age=600"), + asset_cache_control, )) .service(ServeDir::new(static_dir).not_found_service(asset_not_found)), ) diff --git a/src/config.rs b/src/config.rs index 234d703..16e0408 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,8 @@ pub struct Config { pub files_dir: String, pub upload_max_size_bytes: usize, pub asset_version: String, + pub asset_cache_max_age_seconds: u64, + pub file_cache_max_age_seconds: u64, pub smtp: Option, pub registration_enabled: bool, pub account_confirmation_required: bool, @@ -60,6 +62,8 @@ impl Config { .checked_mul(1024 * 1024) .ok_or("UPLOAD_MAX_SIZE_MB is too large")?, asset_version: env!("CARGO_PKG_VERSION").to_owned(), + asset_cache_max_age_seconds: env_nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?, + file_cache_max_age_seconds: env_nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?, smtp, registration_enabled: env_bool("REGISTRATION_ENABLED", false)?, account_confirmation_required: env_bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?, @@ -100,3 +104,7 @@ fn env_positive_i64(name: &str, default: i64) -> Result Result> { + Ok(env_var(name, &default.to_string()).parse()?) +} diff --git a/src/main.rs b/src/main.rs index 5844990..6c0b0a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,8 @@ async fn main() -> Result<(), Box> { static_dir = %config.static_dir, files_dir = %config.files_dir, upload_max_size_bytes = config.upload_max_size_bytes, + asset_cache_max_age_seconds = config.asset_cache_max_age_seconds, + file_cache_max_age_seconds = config.file_cache_max_age_seconds, registration_enabled = config.registration_enabled, account_confirmation_required = config.account_confirmation_required, frontend_log_level = %config.frontend_log_level, @@ -56,6 +58,7 @@ async fn main() -> Result<(), Box> { config.asset_version.clone(), config.files_dir.clone(), config.upload_max_size_bytes, + config.file_cache_max_age_seconds, config.smtp.clone(), config.registration_enabled, config.account_confirmation_required, @@ -67,6 +70,7 @@ async fn main() -> Result<(), Box> { state, &config.static_dir, config.upload_max_size_bytes, + config.asset_cache_max_age_seconds, ); let address = SocketAddr::new(config.host, config.port); let listener = TcpListener::bind(address).await?; diff --git a/src/state.rs b/src/state.rs index c17560b..955effa 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,6 +1,7 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, sync::{Arc, atomic::{AtomicU64, Ordering}}}; use crate::database::Database; use tokio::sync::{broadcast, RwLock}; +use serde::Serialize; const CHANNEL_CAPACITY: usize = 256; @@ -18,36 +19,80 @@ pub struct NoteUpdate { pub owner_map: String, } +#[derive(Debug, Clone, Serialize)] +pub struct PresenceUser { + pub name: String, + pub color: Option, +} + +#[derive(Debug, Clone)] +pub enum RoomEvent { + Document(NoteUpdate), + Presence(Vec), + Chat { sender: String, text: String }, +} + #[derive(Debug)] pub struct AppState { pub db: Database, pub asset_version: String, pub files_dir: String, pub upload_max_size_bytes: usize, + pub file_cache_max_age_seconds: u64, pub smtp: Option, pub registration_enabled: bool, pub account_confirmation_required: bool, pub frontend_log_level: String, pub anonymous_access_token_ttl_days: i64, pub user_session_ttl_days: i64, - channels: RwLock>>, + channels: RwLock>>, + presence: RwLock>>, + next_connection_id: AtomicU64, } impl AppState { - pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, smtp: Option, registration_enabled: bool, account_confirmation_required: bool, frontend_log_level: String, anonymous_access_token_ttl_days: i64, user_session_ttl_days: i64) -> Self { - Self { db, asset_version, files_dir, upload_max_size_bytes, smtp, registration_enabled, account_confirmation_required, frontend_log_level, anonymous_access_token_ttl_days, user_session_ttl_days, channels: RwLock::new(HashMap::new()) } + pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, file_cache_max_age_seconds: u64, smtp: Option, registration_enabled: bool, account_confirmation_required: bool, frontend_log_level: String, anonymous_access_token_ttl_days: i64, user_session_ttl_days: i64) -> Self { + Self { db, asset_version, files_dir, upload_max_size_bytes, file_cache_max_age_seconds, smtp, registration_enabled, account_confirmation_required, frontend_log_level, anonymous_access_token_ttl_days, user_session_ttl_days, channels: RwLock::new(HashMap::new()), presence: RwLock::new(HashMap::new()), next_connection_id: AtomicU64::new(1) } } - async fn channel_for_key(&self, key: String) -> broadcast::Sender { + async fn channel_for_key(&self, key: String) -> broadcast::Sender { if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); } let mut channels = self.channels.write().await; channels.entry(key).or_insert_with(|| broadcast::channel(CHANNEL_CAPACITY).0).clone() } - pub async fn note_channel(&self, workspace_slug: &str, note_slug: &str) -> broadcast::Sender { - self.channel_for_key(format!("workspace:{workspace_slug}/{note_slug}")).await + pub fn note_room_key(workspace_slug: &str, note_slug: &str) -> String { format!("workspace:{workspace_slug}/{note_slug}") } + pub fn pad_room_key(slug: &str) -> String { format!("pad:{slug}") } + pub async fn note_channel(&self, workspace_slug: &str, note_slug: &str) -> broadcast::Sender { self.channel_for_key(Self::note_room_key(workspace_slug, note_slug)).await } + pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender { self.channel_for_key(Self::pad_room_key(slug)).await } + pub async fn join_room(&self, key: &str, nickname: String, color: Option) -> (u64, Vec) { + let id = self.next_connection_id.fetch_add(1, Ordering::Relaxed); + let mut presence = self.presence.write().await; + let room = presence.entry(key.to_owned()).or_default(); + room.insert(id, PresenceUser { name: nickname, color }); + (id, sorted_users(room)) } - pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender { - self.channel_for_key(format!("pad:{slug}")).await + pub async fn update_room_color(&self, key: &str, id: u64, color: Option) -> Vec { + let mut presence = self.presence.write().await; + if let Some(room) = presence.get_mut(key) { + if let Some(user) = room.get_mut(&id) { user.color = color; } + sorted_users(room) + } else { Vec::new() } + } + pub async fn leave_room(&self, key: &str, id: u64) -> Vec { + let mut presence = self.presence.write().await; + if let Some(room) = presence.get_mut(key) { + room.remove(&id); + let users = sorted_users(room); + let empty = room.is_empty(); + if empty { presence.remove(key); } + users + } else { Vec::new() } } } +fn sorted_users(room: &HashMap) -> Vec { + let mut users: Vec = room.values().cloned().collect(); + users.sort_by_key(|value| value.name.to_lowercase()); + users +} + pub type SharedState = Arc; diff --git a/src/websocket.rs b/src/websocket.rs index 6b6d84b..1ca8bd3 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -2,13 +2,17 @@ use axum::{extract::{ws::{Message, WebSocket}, Path, State, WebSocketUpgrade}, r use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use tracing::{debug, info, warn}; -use crate::{auth, db, state::{NoteUpdate, SharedState}}; +use crate::{auth, db, state::{AppState, NoteUpdate, PresenceUser, RoomEvent, SharedState}}; +use std::time::{Duration, Instant}; #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ClientMessage { - Authenticate { password: Option, access_token: Option, nickname: Option, session_token: Option }, + Authenticate { password: Option, access_token: Option, nickname: Option, session_token: Option, color: Option }, Update { content: String, owner_map: Option }, + Ping { nonce: u64 }, + Chat { text: String }, + SetColor { color: Option }, } #[derive(Debug, Serialize)] @@ -16,6 +20,9 @@ enum ClientMessage { enum ServerMessage { Authenticated { workspace_title: String, note_title: String, content: String, owner_map: String }, Document { content: String, revision_id: i64, updated_at: String, author: Option, owner_map: String }, + Presence { users: Vec }, + Chat { sender: String, text: String }, + Pong { nonce: u64 }, Error { message: String }, } @@ -27,9 +34,9 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug info!(%workspace_slug, %note_slug, "note websocket connected"); let Some(workspace) = db::find_workspace(&state.db, &workspace_slug).await.ok().flatten() else { warn!(%workspace_slug, %note_slug, "note websocket rejected: workspace not found"); let _=send_error(&mut socket,"Workspace not found").await; return; }; let Some(note) = db::find_note(&state.db, workspace.id, ¬e_slug).await.ok().flatten() else { warn!(%workspace_slug, %note_slug, "note websocket rejected: note not found"); let _=send_error(&mut socket,"Note not found").await; return; }; - let (password, access_token, nickname, session_token) = match socket.recv().await { + let (password, access_token, nickname, session_token, color) = match socket.recv().await { Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { - Ok(ClientMessage::Authenticate { password, access_token, nickname, session_token }) => (password, access_token, clean_nickname(nickname), session_token), + Ok(ClientMessage::Authenticate { password, access_token, nickname, session_token, color }) => (password, access_token, clean_nickname(nickname), session_token, clean_color(color)), _ => { let _=send_error(&mut socket,"Wymagane uwierzytelnienie").await; return; } }, _ => return }; @@ -37,8 +44,13 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug if workspace.password_hash.is_some() && !db::verify_workspace_password(&workspace, password.as_deref()) && !crate::api::verify_resource_access_token(&state, "workspace", &workspace_slug, access_token.as_deref()).await.unwrap_or(false) { warn!(workspace_id = workspace.id, note_id = note.id, "note websocket rejected: invalid workspace password"); let _=send_error(&mut socket,"Invalid password").await; return; } info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated"); if send(&mut socket,&ServerMessage::Authenticated { workspace_title:workspace.title.clone(), note_title:note.title.clone(), content:note.content.clone(), owner_map:note.owner_map.clone() }).await.is_err(){return;} + let room_key = AppState::note_room_key(&workspace_slug, ¬e_slug); let channel=state.note_channel(&workspace_slug,¬e_slug).await; let mut updates=channel.subscribe(); + let display_name = nickname.clone().unwrap_or_else(|| "Guest".into()); + let (connection_id, users) = state.join_room(&room_key, display_name.clone(), color).await; + let _ = channel.send(RoomEvent::Presence(users)); + let mut last_chat = Instant::now() - Duration::from_secs(1); let (mut sender,mut receiver)=socket.split(); loop { tokio::select! { incoming=receiver.next()=>match incoming { @@ -47,25 +59,41 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug if content.len()>2_000_000 { let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await; continue; } let owner_map=owner_map.unwrap_or_else(||"[]".into()); match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await { - Ok((revision_id,updated_at))=>{let _=channel.send(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map});} + Ok((revision_id,updated_at))=>{let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}));} Err(error)=>warn!(%error, workspace_id = workspace.id, note_id = note.id, "failed to save revision"), } } + Ok(ClientMessage::Ping{nonce})=>{ let _=send_split(&mut sender,&ServerMessage::Pong{nonce}).await; }, + Ok(ClientMessage::Chat{text})=>{ + let text=clean_chat(text); + if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); } + } + Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); }, Ok(ClientMessage::Authenticate{..})=>{}, Err(error)=>warn!(%error,"invalid websocket message"), }, Some(Ok(Message::Close(_)))|None=>break, Some(Ok(_))=>{}, Some(Err(error))=>{debug!(%error,"websocket receive error");break;} }, update=updates.recv()=>match update { - Ok(update)=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;}, + Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;}, + Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;}, + Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;}, Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,¬e_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} }, Err(tokio::sync::broadcast::error::RecvError::Closed)=>break, } }} + let users = state.leave_room(&room_key, connection_id).await; + let _ = channel.send(RoomEvent::Presence(users)); info!(workspace_id = workspace.id, note_id = note.id, "note websocket disconnected"); } fn clean_nickname(value: Option)->Option { value.map(|v|v.trim().chars().take(40).collect::()).filter(|v|!v.is_empty()) } +fn clean_color(value: Option) -> Option { + value.map(|v| v.trim().to_ascii_lowercase()).filter(|v| v.len() == 7 && v.starts_with('#') && v[1..].chars().all(|c| c.is_ascii_hexdigit())) +} +fn clean_chat(value: String) -> String { + value.chars().map(|c| if matches!(c, '\r' | '\n' | '\0') { ' ' } else { c }).collect::().trim().chars().take(1000).collect() +} async fn send_error(socket:&mut WebSocket,message:&str)->Result<(),axum::Error> { send(socket,&ServerMessage::Error { message:message.into() @@ -84,6 +112,9 @@ async fn send_split(sender:&mut futures_util::stream::SplitSink, owner_map: String }, + Presence { users: Vec }, + Chat { sender: String, text: String }, + Pong { nonce: u64 }, Error { message: String }, } pub async fn upgrade_pad(ws:WebSocketUpgrade,Path(slug):Path,State(state):State)->Response{ @@ -92,9 +123,9 @@ pub async fn upgrade_pad(ws:WebSocketUpgrade,Path(slug):Path,State(state async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){ info!(%slug, "pad websocket connected"); let Some(pad)=db::find_pad(&state.db,&slug).await.ok().flatten() else {warn!(%slug, "pad websocket rejected: pad not found");let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Note not found".into()}).await;return;}; - let (password,access_token,nickname,session_token)=match socket.recv().await{ + let (password,access_token,nickname,session_token,color)=match socket.recv().await{ Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text){ - Ok(ClientMessage::Authenticate{password,access_token,nickname,session_token})=>(password,access_token,clean_nickname(nickname),session_token), + Ok(ClientMessage::Authenticate{password,access_token,nickname,session_token,color})=>(password,access_token,clean_nickname(nickname),session_token,clean_color(color)), _=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Wymagane uwierzytelnienie".into()}).await;return;} },_=>return }; @@ -102,8 +133,13 @@ async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){ if pad.password_hash.is_some() && !db::verify_pad_password(&pad,password.as_deref()) && !crate::api::verify_resource_access_token(&state,"pad",&slug,access_token.as_deref()).await.unwrap_or(false){warn!(pad_id = pad.id, "pad websocket rejected: invalid password");let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Invalid password".into()}).await;return;} info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated"); if send_pad(&mut socket,&PadServerMessage::Authenticated{title:pad.title.clone(),content:pad.content.clone(),owner_map:pad.owner_map.clone()}).await.is_err(){return;} + let room_key = AppState::pad_room_key(&slug); let channel=state.pad_channel(&slug).await; let mut updates=channel.subscribe(); + let display_name = nickname.clone().unwrap_or_else(|| "Guest".into()); + let (connection_id, users) = state.join_room(&room_key, display_name.clone(), color).await; + let _ = channel.send(RoomEvent::Presence(users)); + let mut last_chat = Instant::now() - Duration::from_secs(1); let(mut sender,mut receiver)=socket.split(); loop{tokio::select!{ incoming=receiver.next()=>match incoming{ @@ -112,9 +148,15 @@ async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){ if content.len()>2_000_000 { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await; continue; } let owner_map=owner_map.unwrap_or_else(||"[]".into()); if let Ok((revision_id,updated_at))=db::save_pad_revision(&state.db,pad.id,&content,nickname.as_deref(),&owner_map).await{ - let _=channel.send(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}); + let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map})); } } + Ok(ClientMessage::Ping{nonce})=>{ let _=send_pad_split(&mut sender,&PadServerMessage::Pong{nonce}).await; }, + Ok(ClientMessage::Chat{text})=>{ + let text=clean_chat(text); + if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); } + } + Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); }, Ok(ClientMessage::Authenticate{..})=>{}, Err(error)=>warn!(%error,"invalid pad websocket message"), }, @@ -123,11 +165,15 @@ async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){ Some(Err(error))=>{debug!(%error,"pad websocket receive error");break;} }, update=updates.recv()=>match update{ - Ok(u)=>if send_pad_split(&mut sender,&PadServerMessage::Document{content:u.content,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,owner_map:u.owner_map}).await.is_err(){break;}, + Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{content:u.content,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,owner_map:u.owner_map}).await.is_err(){break;}, + Ok(RoomEvent::Presence(users))=>if send_pad_split(&mut sender,&PadServerMessage::Presence{users}).await.is_err(){break;}, + Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_pad_split(&mut sender,&PadServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;}, Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_pad(&state.db,&slug).await { if send_pad_split(&mut sender,&PadServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} }, Err(tokio::sync::broadcast::error::RecvError::Closed)=>break, } }} + let users = state.leave_room(&room_key, connection_id).await; + let _ = channel.send(RoomEvent::Presence(users)); info!(pad_id = pad.id, "pad websocket disconnected"); } async fn send_pad(socket:&mut WebSocket,message:&PadServerMessage)->Result<(),axum::Error> { diff --git a/static/css/styles.css b/static/css/styles.css index eb3b255..18cf177 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -218,8 +218,11 @@ dialog::backdrop { background: rgba(4,6,9,.82); } .editor-shell textarea { padding-left: 18px; } .line-toggle { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: .78rem; white-space: nowrap; } .line-toggle input { width: auto; min-height: auto; margin: 0; accent-color: var(--accent); } -.user-chip { display: inline-flex; align-items: center; gap: 7px; color: #dce2eb; font-size: .78rem; } -.user-chip::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--owner, var(--accent)); } +.user-color-control { position: relative; display: inline-flex; align-items: center; } +.user-chip { display: inline-flex; align-items: center; gap: 7px; padding: 4px 6px; border: 0; border-radius: 7px; background: transparent; color: #dce2eb; font: inherit; font-size: .78rem; } +.user-chip:hover, .user-chip:focus-visible { background: var(--surface-2); outline: none; } +.user-chip__dot { width: 10px; height: 10px; flex: 0 0 auto; border: 1px solid color-mix(in srgb, var(--owner, var(--accent)) 72%, white); border-radius: 50%; background: var(--owner, var(--accent)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--owner, var(--accent)) 18%, transparent); } +.user-color-picker { position: absolute; top: calc(100% + 4px); left: 0; width: 1px; height: 1px; padding: 0; border: 0; opacity: 0; pointer-events: none; } .dialog-copy { margin: 0 0 4px; color: var(--muted); line-height: 1.5; } .history-header h2 { margin: 0; } .history-header p { margin: 4px 0 0; color: var(--muted-2); font-size: .75rem; } @@ -230,7 +233,7 @@ dialog::backdrop { background: rgba(4,6,9,.82); } .revision__preview { max-height: 180px; overflow: auto; margin-top: 10px; padding: 10px; border: 1px solid var(--border); border-radius: 7px; background: #0b0e13; color: #c9d0da; font: .72rem/1.5 ui-monospace, monospace; white-space: pre-wrap; } .revision button + button { margin-left: 12px; } .mermaid { overflow: auto; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: #0a0d12; } -@media (max-width: 720px) { .line-gutter { width: 48px; padding-top: 18px; font-size: 15px; } .editor-shell textarea { padding: 18px 12px; font-size: 15px; } .user-chip { display: none; } } +@media (max-width: 720px) { .line-gutter { width: 48px; padding-top: 18px; font-size: 15px; } .editor-shell textarea { padding: 18px 12px; font-size: 15px; } .user-color-control { display: none; } } .markdown-body img { display: block; max-width: 100%; height: auto; margin: 16px auto; border-radius: 10px; } .markdown-body a { overflow-wrap: anywhere; } @@ -549,3 +552,66 @@ dialog::backdrop { background: rgba(4,6,9,.82); } .resource-delete-confirm > p:first-child { margin: 0; } @media (max-width: 640px) { .resource-main { align-items: flex-start; flex-direction: column; } .resource-actions { width: 100%; } } .resource-row { align-items: stretch; flex-direction: column; } + +/* Stable scrolling and collapsible Markdown sections. */ +.editor-shell { width: 100%; max-width: 100%; } +.editor-shell textarea { min-width: 0; max-width: 100%; overscroll-behavior: contain; overflow-anchor: none; } +.line-gutter { position: relative; z-index: 5; flex: none; } +.preview, .editor-shell textarea { scrollbar-gutter: stable; } +.markdown-details { margin: .65em 0; border: 1px solid var(--border); border-radius: 8px; background: rgba(255,255,255,.015); } +.markdown-details > summary { padding: .55em .8em; color: var(--text); font-weight: 650; cursor: pointer; user-select: none; } +.markdown-details[open] > summary { border-bottom: 1px solid var(--border); } +.markdown-details__content { padding: .35em .8em .7em; } +.markdown-details__content > :first-child { margin-top: 0; } +.markdown-details__content > :last-child { margin-bottom: 0; } + + +/* Full-height editor: keep header/footer visible and scroll only the work area. */ +.pad-page { height: 100dvh; min-height: 0; overflow: hidden; display: grid; grid-template-rows: auto minmax(0, 1fr); } +.pad-page .app-header { min-width: 0; } +.pad-page .editor-layout { height: auto; min-height: 0; overflow: hidden; } +.pad-page .editor-panel { min-height: 0; overflow: hidden; } +.pad-page .workspace { min-height: 0; overflow: hidden; } +.pad-page .editor-column, +.pad-page .preview-column, +.pad-page .editor-shell { min-height: 0; overflow: hidden; } +.pad-page .editor-shell textarea, +.pad-page .preview { height: 100%; min-height: 0; overflow: auto; } +.pad-page .editor-footer { position: relative; z-index: 8; flex: none; background: var(--surface); } +@media (max-width: 980px) { + .pad-page .editor-layout { height: auto; } +} + +/* A collapsible section has one source-line marker; its rendered Markdown does not repeat line numbers inside. */ +.markdown-details__content .preview-source-line::before { display: none; } +.markdown-details__content { overflow: visible; } +.markdown-details__content pre { overflow: auto; } + +/* Ephemeral room presence and chat */ +.room-details { position: relative; display: inline-block; } +.room-details > summary { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; color: var(--text); list-style: none; } +.room-details > summary::-webkit-details-marker { display: none; } +.chat-unread { min-width: 17px; height: 17px; padding: 0 5px; border-radius: 999px; background: var(--accent); color: #fff; font-size: 10px; line-height: 17px; text-align: center; } +.room-popover { position: absolute; bottom: calc(100% + 10px); left: 0; z-index: 40; display: grid; grid-template-columns: 150px minmax(280px, 380px); width: min(560px, calc(100vw - 24px)); max-height: min(430px, 70vh); overflow: hidden; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: 0 18px 50px rgb(0 0 0 / .38); color: var(--text); } +.room-users { padding: 14px; overflow: auto; border-right: 1px solid var(--border); } +.room-users strong, .room-chat__head strong { display: block; margin-bottom: 8px; font-size: .75rem; } +.room-users ul { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; } +.room-users li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); } +.room-chat { display: grid; grid-template-rows: auto minmax(120px, 1fr) auto; min-height: 280px; } +.room-chat__head { padding: 12px 14px 8px; border-bottom: 1px solid var(--border); } +.room-chat__head strong { margin: 0; } +.room-chat__head span { color: var(--muted-2); font-size: .66rem; } +.chat-messages { display: flex; flex-direction: column; gap: 8px; overflow-y: auto; padding: 12px 14px; } +.chat-message { margin: 0; overflow-wrap: anywhere; line-height: 1.35; } +.chat-message strong { margin-right: 6px; color: var(--text); } +.chat-message span { color: var(--muted); } +.chat-empty { margin: auto; color: var(--muted-2); } +.chat-form { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 10px; border-top: 1px solid var(--border); } +.chat-form input { min-width: 0; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--text); } +.chat-form button { padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--accent); color: #fff; cursor: pointer; } +@media (max-width: 700px) { .room-popover { grid-template-columns: 1fr; left: auto; right: -120px; } .room-users { max-height: 110px; border-right: 0; border-bottom: 1px solid var(--border); } } +.room-user { display: flex; align-items: center; gap: 8px; min-width: 0; } +.room-user__dot { width: 9px; height: 9px; flex: 0 0 auto; border-radius: 50%; background: var(--owner, var(--accent)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--owner, var(--accent)) 18%, transparent); } +.room-user > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chat-message a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; overflow-wrap: anywhere; } +.chat-message a:hover { filter: brightness(1.15); } diff --git a/static/js/editor-format.js b/static/js/editor-format.js index bbd4563..cd05c00 100644 --- a/static/js/editor-format.js +++ b/static/js/editor-format.js @@ -50,6 +50,7 @@ export function applyFormat(editor, format) { if (format === "subscript") toggleWrap(editor, "~", "~", "2"); if (format === "superscript") toggleWrap(editor, "^", "^", "2"); if (format === "codeblock") toggleWrap(editor, "```text\n", "\n```", "code"); + if (format === "details") toggleWrap(editor, "
\nClick me\n\n", "\n
", "Content"); if (format === "table") toggleWrap(editor, "| Column 1 | Column 2 |\n| --- | --- |\n| ", " | value |", "value"); if (format === "footnote") toggleWrap(editor, "", "[^1]\n\n[^1]: Footnote text", "Text with footnote"); if (format === "definition") toggleWrap(editor, "", "\n: Definition", "Term"); diff --git a/static/js/markdown.js b/static/js/markdown.js index c803561..e4c759d 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -54,7 +54,7 @@ function inline(value) { return html.replace(/\u0000T(\d+)\u0000/g, (_, index) => tokens[Number(index)] || ""); } -const attrs = (line, editable = false, prefix = "", suffix = "") => ` class="preview-source-line${editable ? " preview-editable" : ""}" data-source-line="${line + 1}"${editable ? ` contenteditable="true" spellcheck="true" data-source-prefix="${escapeHtml(prefix)}" data-source-suffix="${escapeHtml(suffix)}"` : ""}`; +const attrs = (line, editable = false, prefix = "", suffix = "", lineOffset = 0) => ` class="preview-source-line${editable ? " preview-editable" : ""}" data-source-line="${line + lineOffset + 1}"${editable ? ` contenteditable="true" spellcheck="true" data-source-prefix="${escapeHtml(prefix)}" data-source-suffix="${escapeHtml(suffix)}"` : ""}`; const isPlainText = line => !/[`*_~^=\[\]<>|:#]/.test(line) && !/^\s*(?:[-+*>]|\d+\.)\s/.test(line); function splitTableRow(line) { @@ -70,7 +70,7 @@ function tableDelimiter(line) { return cells.map(cell => cell.startsWith(":") && cell.endsWith(":") ? "center" : cell.endsWith(":") ? "right" : "left"); } -export function renderMarkdown(source) { +export function renderMarkdown(source, lineOffset = 0) { let html = "", inCode = false, fence = "", language = "", code = [], codeStart = 0, list = null; const lines = String(source).split("\n"); const footnotes = new Map(); @@ -93,14 +93,14 @@ export function renderMarkdown(source) { const closeCode = () => { const body = escapeHtml(code.join("\n")); html += language.toLowerCase() === "mermaid" - ? `
${body}
` - : `${body}`; + ? `
${body}
` + : `${body}`; code = []; language = ""; fence = ""; }; for (let index = 0; index < lines.length; index++) { const line = lines[index]; - const fenceMatch = line.match(/^(```+|~~~+)\s*([^\s]*)\s*$/); + const fenceMatch = line.match(/^\s*(```+|~~~+)\s*([^\s]*)\s*$/); if (fenceMatch) { closeList(); if (inCode && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) closeCode(); @@ -114,14 +114,14 @@ export function renderMarkdown(source) { if (line.includes("|") && delimiter) { closeList(); const headers = splitTableRow(line); - html += `
`; - headers.forEach((cell, i) => html += ``); + html += `
${inline(cell)}
`; + headers.forEach((cell, i) => html += ``); html += ``; index += 2; while (index < lines.length && lines[index].includes("|") && lines[index].trim()) { const cells = splitTableRow(lines[index]); html += ``; - headers.forEach((_, i) => html += ``); + headers.forEach((_, i) => html += ``); html += ``; index++; } @@ -130,39 +130,62 @@ export function renderMarkdown(source) { continue; } - const heading = line.match(/^(#{1,6})\s+(.+?)(?:\s+\{#([A-Za-z][\w:.-]*)\})?\s*$/); + if (/^
\s*$/i.test(line.trim())) { + closeList(); + let end = index + 1; + while (end < lines.length && !/^<\/details>\s*$/i.test(lines[end].trim())) end++; + if (end < lines.length) { + const block = lines.slice(index + 1, end); + let summary = "Details"; + while (block.length && !block[0].trim()) block.shift(); + if (block.length) { + const summaryMatch = block[0].trim().match(/^([\s\S]*?)<\/summary>$/i); + if (summaryMatch) { summary = summaryMatch[1].trim() || "Details"; block.shift(); } + } + while (block.length && !block[0].trim()) block.shift(); + html += `
${inline(summary)}
${renderMarkdown(block.join("\n"), lineOffset + index + 1)}
`; + index = end; + continue; + } + } + + const heading = line.match(/^\s{0,4}(#{1,6})\s+(.+?)(?:\s+\{#([A-Za-z][\w:.-]*)\})?\s*$/); const task = line.match(/^(\s*)[-*+]\s+\[([ xX])\]\s+(.+)$/); - const ul = line.match(/^\s*[-*+]\s+(.+)$/); - const ol = line.match(/^\s*\d+\.\s+(.+)$/); + const ul = line.match(/^(\s*)[-*+]\s+(.+)$/); + const ol = line.match(/^(\s*)\d+\.\s+(.+)$/); if (heading) { closeList(); const n = heading[1].length; const id = heading[3] ? ` id="${escapeHtml(heading[3])}"` : ""; const suffix = heading[3] ? ` {#${heading[3]}}` : ""; - html += `${inline(heading[2])}`; + html += `${inline(heading[2])}`; } else if (task) { if (list !== "ul") { closeList(); html += `
    `; list = "ul"; } const checked = task[2].toLowerCase() === "x"; - html += `
  • ${inline(task[3])}
  • `; + html += `
  • ${inline(task[3])}
  • `; } else if (ul || ol) { const type = ul ? "ul" : "ol"; if (list !== type) { closeList(); html += `<${type}>`; list = type; } - html += `${inline((ul || ol)[1])}`; + const match = ul || ol; + const indentWidth = match[1].replace(/\t/g, " ").length; + const prefix = ul ? `${match[1]}- ` : `${match[1]}${(line.match(/^\s*(\d+)\./)||[])[1] || 1}. `; + const indentStyle = indentWidth ? ` style="margin-left:${Math.min(indentWidth, 24) * 0.45}em"` : ""; + html += `${inline(match[2])}`; } else { closeList(); const definition = index + 1 < lines.length && /^:\s+/.test(lines[index + 1]); if (line.trim() && definition) { - html += `
    ${inline(line)}
    `; + html += `
    ${inline(line)}
    `; while (index + 1 < lines.length && /^:\s+/.test(lines[index + 1])) { index++; - html += `
    ${inline(lines[index].replace(/^:\s+/, ""))}
    `; + html += `
    ${inline(lines[index].replace(/^:\s+/, ""))}
    `; } html += ``; - } else if (/^---+$/.test(line.trim())) html += ``; - else if (line.startsWith("> ")) html += ` ")}>${inline(line.slice(2))}`; - else if (line.trim()) html += `${inline(line)}

    `; - else html += `
    `; + } else if (/^---+$/.test(line.trim())) html += ``; + else if (line.startsWith("> ")) html += ` ", "", lineOffset)}>${inline(line.slice(2))}`; + else if (line.trim()) html += `${inline(line)}

    `; + else html += `
    `; } } diff --git a/static/js/note.js b/static/js/note.js index 2977529..f619d16 100644 --- a/static/js/note.js +++ b/static/js/note.js @@ -15,14 +15,29 @@ import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url const parts=location.pathname.split("/").filter(Boolean), workspaceSlug=parts[1], noteSlug=parts[3]; const editor=document.querySelector("#editor"), preview=document.querySelector("#preview"), editorWorkspace=document.querySelector("#editor-workspace"), gutter=document.querySelector("#line-gutter"), ownerLabels=document.querySelector("#owner-labels"); const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document.querySelector("#password-dialog"), identityDialog=document.querySelector("#identity-dialog"); -const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"); +const 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"); +let unreadChat=0; +const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"), currentUser=document.querySelector("#current-user"), userColorPicker=document.querySelector("#user-color-picker"); let accessToken=getAccessToken("workspace",workspaceSlug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[]; const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off"; compactToggle.checked=localStorage.getItem("rustpad:compact")!=="off"; fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono"; fontSize.value=localStorage.getItem("rustpad:font-size")||"14"; -function colorFor(name){let h=0;for(const c of name||"?")h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 70% 62%)`;} +function defaultColorFor(name){let h=0;for(const c of name||"?")h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 70% 62%)`;} +function storedColorKey(name){return `rustpad:user-color:${encodeURIComponent(name||"")}`;} +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;} +function colorFor(owner){const parts=ownerParts(owner);return /^#[0-9a-f]{6}$/i.test(parts.color)?parts.color:defaultColorFor(parts.name);} +function currentUserColor(){return localStorage.getItem(storedColorKey(nickname))||"";} +function currentOwner(){const color=currentUserColor();return color?`${nickname}\u001f${color}`:nickname;} +function updateCurrentUser(){const color=currentUserColor()||defaultColorFor(nickname);currentUser.querySelector(".user-chip__name").textContent=nickname;currentUser.style.setProperty("--owner",color);userColorPicker.value=/^#[0-9a-f]{6}$/i.test(color)?color:"#7c6cff";} function toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);} +function updatePresence(users){const entries=Array.isArray(users)?users:[];roomCount.textContent=`${entries.length} ${entries.length===1?"user":"users"}`;roomUsers.replaceChildren(...entries.map(entry=>{const user=typeof entry==="string"?{name:entry,color:""}:entry||{};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);}} +function updateLatency(ms){socketLatency.textContent=Number.isFinite(ms)?`${ms} ms`:"— ms";} +function appendLinkifiedText(container,value){const text=String(value||"");const urlPattern=/https?:\/\/[^\s<>{}\[\]"'`]+/gi;let index=0;for(const match of text.matchAll(urlPattern)){const start=match.index??0;if(start>index)container.append(document.createTextNode(text.slice(index,start)));let raw=match[0],trail="";while(/[),.!?:;]$/.test(raw)){trail=raw.slice(-1)+trail;raw=raw.slice(0,-1);}try{const url=new URL(raw);if(url.protocol==="http:"||url.protocol==="https:"){const link=document.createElement("a");link.href=url.href;link.textContent=raw;link.target="_blank";link.rel="noopener noreferrer";container.append(link);}else container.append(document.createTextNode(raw));}catch{container.append(document.createTextNode(raw));}if(trail)container.append(document.createTextNode(trail));index=start+match[0].length;}if(index100)chatMessages.firstElementChild.remove();chatMessages.scrollTop=chatMessages.scrollHeight;if(message.sender!==nickname&&!roomDetails.open){unreadChat++;chatUnread.hidden=false;chatUnread.textContent=unreadChat>99?"99+":String(unreadChat);const oldTitle=document.title;if(!document.title.startsWith("● "))document.title=`● ${oldTitle}`;if(document.hidden&&Notification.permission==="granted")new Notification(`${message.sender} wrote in RustPad`,{body:message.text.slice(0,160),tag:"rustpad-room-chat"});}} +function clearUnread(){unreadChat=0;chatUnread.hidden=true;chatUnread.textContent="";document.title=document.title.replace(/^● /,"");} + function setStatus(kind,text){document.querySelector("#status-dot").className=`status__dot${kind?` is-${kind}`:""}`;document.querySelector("#status-text").textContent=text;} function updateAddressLabel(){document.querySelector("#note-url").textContent=`${location.pathname}${location.search}`;} async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'

    Failed to load Mermaid.

    '));}} @@ -31,7 +46,7 @@ function renderGutter(){ const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1); const lines=Array.from({length:lineCount}); owners=owners.slice(0,lineCount); - while(owners.length`
    ${i+1}
    `).join(""); @@ -40,7 +55,7 @@ function renderGutter(){ const owner=owners[i]||""; if(!owner)return ""; const top=paddingTop+i*lineHeight-editor.scrollTop; - const label=owner!==owners[i-1]?`${escapeHtml(owner)}`:""; + const label=owner!==owners[i-1]?`${escapeHtml(ownerName(owner))}`:""; return `${label}`; }).join(""); document.body.classList.toggle("hide-line-numbers",!lineToggle.checked); @@ -65,6 +80,48 @@ function markdownFromPreview(node){ }; return [...node.childNodes].map(walk).join("").replace(/\n/g," ").trim(); } + +function previewCaretOffset(target){ + const selection=window.getSelection(); + if(!selection?.rangeCount)return 0; + const range=selection.getRangeAt(0); + if(!target.contains(range.startContainer))return 0; + const prefix=range.cloneRange(); + prefix.selectNodeContents(target); + prefix.setEnd(range.startContainer,range.startOffset); + return prefix.toString().length; +} +function placePreviewCaret(target,offset){ + const walker=document.createTreeWalker(target,NodeFilter.SHOW_TEXT); + let remaining=Math.max(0,offset),node; + while((node=walker.nextNode())){ + if(remaining<=node.nodeValue.length){ + const range=document.createRange();range.setStart(node,remaining);range.collapse(true); + const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);return; + } + remaining-=node.nodeValue.length; + } + const range=document.createRange();range.selectNodeContents(target);range.collapse(false); + const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range); +} +function movePreviewCaret(target,direction){ + const editables=[...preview.querySelectorAll(".preview-editable")]; + const index=editables.indexOf(target),next=editables[index+direction]; + if(!next)return false; + const offset=previewCaretOffset(target);next.focus();placePreviewCaret(next,offset);next.scrollIntoView({block:"nearest"});return true; +} +function continueIndentation(event){ + if(event.key!=="Enter"||event.shiftKey||event.ctrlKey||event.metaKey||event.altKey)return; + const start=editor.selectionStart,end=editor.selectionEnd; + const lineStart=editor.value.lastIndexOf("\n",start-1)+1; + const current=editor.value.slice(lineStart,start); + const indent=(current.match(/^[ \t]*/)||[""])[0]; + if(!indent)return; + event.preventDefault(); + editor.setRangeText(`\n${indent}`,start,end,"end"); + editor.dispatchEvent(new Event("input",{bubbles:true})); +} + function replaceTableCell(line,index,value){ const leading=line.trimStart().startsWith("|"),trailing=line.trimEnd().endsWith("|"); let body=line.trim();if(leading)body=body.slice(1);if(trailing)body=body.slice(0,-1); @@ -74,7 +131,7 @@ function replaceTableCell(line,index,value){ function render(){if(uiState.mode==="markdown"){preview.classList.remove("preview--raw");preview.innerHTML=renderMarkdown(editor.value);document.querySelector("#preview-label").textContent="Markdown + Mermaid preview · text and headings are editable";renderMermaid();renderCodeHighlight();}else{preview.classList.add("preview--raw");preview.innerHTML=editor.value.split("\n").map((line,index)=>`
    ${escapeHtml(line)||"
    "}
    `).join("");document.querySelector("#preview-label").textContent="Text preview · editable";}document.querySelector("#characters").textContent=`${editor.value.length} characters`;document.querySelector("#words").textContent=`${editor.value.trim()?editor.value.trim().split(/\s+/).length:0} words`;renderGutter();} function applyUi({write=false,replace=false}={}){editorWorkspace.className=`workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`;editorWorkspace.style.setProperty("--editor-font-size",`${fontSize.value}px`);document.body.classList.toggle("compact-editor",compactToggle.checked);document.querySelectorAll("[data-view]").forEach(b=>{const a=b.dataset.view===uiState.view;b.classList.toggle("active",a);b.setAttribute("aria-pressed",String(a));});const markdown=uiState.mode==="markdown";modeToggle.classList.toggle("active",markdown);modeToggle.textContent=markdown?"Markdown":"Text";render();if(write)writeEditorState(uiState,{replace});updateAddressLabel();} function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();} -function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,accessToken,nickname,sessionToken:getAuthToken(),onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(/nickname|session|account/i.test(m)){if(!identityDialog.open)identityDialog.showModal();}else if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();} +function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,accessToken,nickname,color:currentUserColor()||null,sessionToken:getAuthToken(),onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onPresence:updatePresence,onLatency:updateLatency,onChat:appendChatMessage,onError:m=>{document.querySelector("#password-error").textContent=m;if(/nickname|session|account/i.test(m)){if(!identityDialog.open)identityDialog.showModal();}else if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();} function formatBytes(bytes){const value=Number(bytes)||0;if(value<1024)return `${value} B`;if(value<1024*1024)return `${(value/1024).toFixed(1)} KB`;return `${(value/1024/1024).toFixed(1)} MB`;} async function loadFiles({open=false}={}){ @@ -86,14 +143,27 @@ async function loadFiles({open=false}={}){ if(open&&!document.querySelector("#files-dialog").open)document.querySelector("#files-dialog").showModal(); }catch(error){toast(error.message);} } -bindIdentityDialog({dialog:identityDialog,onIdentity:async value=>{nickname=value;identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}}); +bindIdentityDialog({dialog:identityDialog,onIdentity:async value=>{nickname=value;identityDialog.close();updateCurrentUser();if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}}); identityDialog.addEventListener("close",()=>{if(!nickname)queueMicrotask(()=>{if(!identityDialog.open)identityDialog.showModal();});}); -async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`

    Note not found

    ${escapeHtml(e.message)}

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

    Note not found

    ${escapeHtml(e.message)}

    `;}} document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();}); -window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>{applyFormat(editor,b.dataset.format);b.closest("details")?.removeAttribute("open");}));bindFormatShortcuts(editor);document.querySelector("#shortcuts-button").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").showModal());document.querySelector("#close-shortcuts").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").close());preview.addEventListener("change",event=>{const checkbox=event.target.closest(".task-checkbox");if(!checkbox)return;const lineIndex=Number(checkbox.dataset.sourceLine)-1;const lines=editor.value.split("\n");if(lineIndex<0||lineIndex>=lines.length)return;lines[lineIndex]=lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/,`$1${checkbox.checked?"x":" "}$2`);editor.value=lines.join("\n");editor.dispatchEvent(new Event("input",{bubbles:true}));});preview.addEventListener("keydown",event=>{const target=event.target.closest(".preview-editable");if(!target)return;if(event.key==="Enter"){event.preventDefault();target.blur();}});preview.addEventListener("blur",event=>{const target=event.target.closest(".preview-editable");if(!target)return;const lineIndex=Number(target.dataset.sourceLine)-1;if(lineIndex<0)return;const lines=editor.value.split("\n");const 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}); +window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>{applyFormat(editor,b.dataset.format);b.closest("details")?.removeAttribute("open");}));bindFormatShortcuts(editor);document.querySelector("#shortcuts-button").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").showModal());document.querySelector("#close-shortcuts").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").close());preview.addEventListener("change",event=>{const checkbox=event.target.closest(".task-checkbox");if(!checkbox)return;const lineIndex=Number(checkbox.dataset.sourceLine)-1;const lines=editor.value.split("\n");if(lineIndex<0||lineIndex>=lines.length)return;lines[lineIndex]=lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/,`$1${checkbox.checked?"x":" "}$2`);editor.value=lines.join("\n");editor.dispatchEvent(new Event("input",{bubbles:true}));});preview.addEventListener("keydown",event=>{const target=event.target.closest(".preview-editable");if(!target)return;if(event.key==="Enter"){event.preventDefault();target.blur();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}); publicTaskUpdates.addEventListener("change",async()=>{publicTaskUpdates.disabled=true;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,allow_task_updates:publicTaskUpdates.checked})});toast(publicTaskUpdates.checked?"Public task updates enabled":"Public task updates disabled");}catch(error){publicTaskUpdates.checked=!publicTaskUpdates.checked;toast(error.message);}finally{publicTaskUpdates.disabled=false;}});document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,allow_task_updates:publicTaskUpdates.checked})});const url=new URL(result.url,location.origin).href;await copyText(url);toast("Page link copied");window.open(url,"_blank","noopener");}catch(error){toast(error.message);}}); -editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.lengthsocket?.update(editor.value,JSON.stringify(owners)),250);}); +roomDetails.addEventListener("toggle",()=>{if(roomDetails.open){clearUnread();chatInput.focus();if("Notification" in window&&Notification.permission==="default")Notification.requestPermission().catch(()=>{});}}); +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();}); +if(!chatMessages.children.length){const empty=document.createElement("p");empty.className="chat-empty";empty.textContent="No messages yet";chatMessages.append(empty);} +currentUser.addEventListener("click",()=>userColorPicker.click()); +userColorPicker.addEventListener("input",()=>{ + localStorage.setItem(storedColorKey(nickname),userColorPicker.value); + const replacement=currentOwner(); + owners=owners.map(owner=>ownerName(owner)===nickname?replacement:owner); + updateCurrentUser();render(); + socket?.setColor(userColorPicker.value); + if(socket)socket.update(editor.value,JSON.stringify(owners)); +}); +editor.addEventListener("keydown",continueIndentation);editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.lengthsocket?.update(editor.value,JSON.stringify(owners)),250);}); document.querySelector("#password-form").addEventListener("submit",async e=>{e.preventDefault();try{password=document.querySelector("#open-password").value;const result=await api("/api/access-token",{method:"POST",body:JSON.stringify({kind:"workspace",slug:workspaceSlug,password})});accessToken=result.access_token;setAccessToken("workspace",workspaceSlug,accessToken);password="";document.querySelector("#open-password").value="";document.querySelector("#password-error").textContent="";loadFiles();connect();}catch(error){document.querySelector("#password-error").textContent=error.message;}}); const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='

    Loading…

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

    ${snippet}

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

    No history yet.

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

    ${escapeHtml(e.message)}

    `;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");}); document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("access_token",accessToken||"");form.append("file",file);try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");loadFiles();}catch(err){toast(err.message);}e.target.value="";}); diff --git a/static/js/pad.js b/static/js/pad.js index 0a7a323..30e6a09 100644 --- a/static/js/pad.js +++ b/static/js/pad.js @@ -14,14 +14,29 @@ import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url const slug=location.pathname.split("/").filter(Boolean)[1]; const editor=document.querySelector("#editor"), preview=document.querySelector("#preview"), editorWorkspace=document.querySelector("#editor-workspace"), gutter=document.querySelector("#line-gutter"), ownerLabels=document.querySelector("#owner-labels"); const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document.querySelector("#password-dialog"), identityDialog=document.querySelector("#identity-dialog"); -const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"); +const 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"); +let unreadChat=0; +const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"), currentUser=document.querySelector("#current-user"), userColorPicker=document.querySelector("#user-color-picker"); let accessToken=getAccessToken("pad",slug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[]; const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off"; compactToggle.checked=localStorage.getItem("rustpad:compact")!=="off"; fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono"; fontSize.value=localStorage.getItem("rustpad:font-size")||"14"; -function colorFor(name){let h=0;for(const c of name||"?")h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 70% 62%)`;} +function defaultColorFor(name){let h=0;for(const c of name||"?")h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 70% 62%)`;} +function storedColorKey(name){return `rustpad:user-color:${encodeURIComponent(name||"")}`;} +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;} +function colorFor(owner){const parts=ownerParts(owner);return /^#[0-9a-f]{6}$/i.test(parts.color)?parts.color:defaultColorFor(parts.name);} +function currentUserColor(){return localStorage.getItem(storedColorKey(nickname))||"";} +function currentOwner(){const color=currentUserColor();return color?`${nickname}\u001f${color}`:nickname;} +function updateCurrentUser(){const color=currentUserColor()||defaultColorFor(nickname);currentUser.querySelector(".user-chip__name").textContent=nickname;currentUser.style.setProperty("--owner",color);userColorPicker.value=/^#[0-9a-f]{6}$/i.test(color)?color:"#7c6cff";} function toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);} +function updatePresence(users){const entries=Array.isArray(users)?users:[];roomCount.textContent=`${entries.length} ${entries.length===1?"user":"users"}`;roomUsers.replaceChildren(...entries.map(entry=>{const user=typeof entry==="string"?{name:entry,color:""}:entry||{};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);}} +function updateLatency(ms){socketLatency.textContent=Number.isFinite(ms)?`${ms} ms`:"— ms";} +function appendLinkifiedText(container,value){const text=String(value||"");const urlPattern=/https?:\/\/[^\s<>{}\[\]"'`]+/gi;let index=0;for(const match of text.matchAll(urlPattern)){const start=match.index??0;if(start>index)container.append(document.createTextNode(text.slice(index,start)));let raw=match[0],trail="";while(/[),.!?:;]$/.test(raw)){trail=raw.slice(-1)+trail;raw=raw.slice(0,-1);}try{const url=new URL(raw);if(url.protocol==="http:"||url.protocol==="https:"){const link=document.createElement("a");link.href=url.href;link.textContent=raw;link.target="_blank";link.rel="noopener noreferrer";container.append(link);}else container.append(document.createTextNode(raw));}catch{container.append(document.createTextNode(raw));}if(trail)container.append(document.createTextNode(trail));index=start+match[0].length;}if(index100)chatMessages.firstElementChild.remove();chatMessages.scrollTop=chatMessages.scrollHeight;if(message.sender!==nickname&&!roomDetails.open){unreadChat++;chatUnread.hidden=false;chatUnread.textContent=unreadChat>99?"99+":String(unreadChat);const oldTitle=document.title;if(!document.title.startsWith("● "))document.title=`● ${oldTitle}`;if(document.hidden&&Notification.permission==="granted")new Notification(`${message.sender} wrote in RustPad`,{body:message.text.slice(0,160),tag:"rustpad-room-chat"});}} +function clearUnread(){unreadChat=0;chatUnread.hidden=true;chatUnread.textContent="";document.title=document.title.replace(/^● /,"");} + function setStatus(kind,text){document.querySelector("#status-dot").className=`status__dot${kind?` is-${kind}`:""}`;document.querySelector("#status-text").textContent=text;} function updateAddressLabel(){document.querySelector("#pad-url").textContent=`${location.pathname}${location.search}`;} async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'

    Failed to load Mermaid.

    '));}} @@ -30,7 +45,7 @@ function renderGutter(){ const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1); const lines=Array.from({length:lineCount}); owners=owners.slice(0,lineCount); - while(owners.length`
    ${i+1}
    `).join(""); @@ -39,7 +54,7 @@ function renderGutter(){ const owner=owners[i]||""; if(!owner)return ""; const top=paddingTop+i*lineHeight-editor.scrollTop; - const label=owner!==owners[i-1]?`${escapeHtml(owner)}`:""; + const label=owner!==owners[i-1]?`${escapeHtml(ownerName(owner))}`:""; return `${label}`; }).join(""); document.body.classList.toggle("hide-line-numbers",!lineToggle.checked); @@ -64,6 +79,48 @@ function markdownFromPreview(node){ }; return [...node.childNodes].map(walk).join("").replace(/\n/g," ").trim(); } + +function previewCaretOffset(target){ + const selection=window.getSelection(); + if(!selection?.rangeCount)return 0; + const range=selection.getRangeAt(0); + if(!target.contains(range.startContainer))return 0; + const prefix=range.cloneRange(); + prefix.selectNodeContents(target); + prefix.setEnd(range.startContainer,range.startOffset); + return prefix.toString().length; +} +function placePreviewCaret(target,offset){ + const walker=document.createTreeWalker(target,NodeFilter.SHOW_TEXT); + let remaining=Math.max(0,offset),node; + while((node=walker.nextNode())){ + if(remaining<=node.nodeValue.length){ + const range=document.createRange();range.setStart(node,remaining);range.collapse(true); + const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);return; + } + remaining-=node.nodeValue.length; + } + const range=document.createRange();range.selectNodeContents(target);range.collapse(false); + const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range); +} +function movePreviewCaret(target,direction){ + const editables=[...preview.querySelectorAll(".preview-editable")]; + const index=editables.indexOf(target),next=editables[index+direction]; + if(!next)return false; + const offset=previewCaretOffset(target);next.focus();placePreviewCaret(next,offset);next.scrollIntoView({block:"nearest"});return true; +} +function continueIndentation(event){ + if(event.key!=="Enter"||event.shiftKey||event.ctrlKey||event.metaKey||event.altKey)return; + const start=editor.selectionStart,end=editor.selectionEnd; + const lineStart=editor.value.lastIndexOf("\n",start-1)+1; + const current=editor.value.slice(lineStart,start); + const indent=(current.match(/^[ \t]*/)||[""])[0]; + if(!indent)return; + event.preventDefault(); + editor.setRangeText(`\n${indent}`,start,end,"end"); + editor.dispatchEvent(new Event("input",{bubbles:true})); +} + function replaceTableCell(line,index,value){ const leading=line.trimStart().startsWith("|"),trailing=line.trimEnd().endsWith("|"); let body=line.trim();if(leading)body=body.slice(1);if(trailing)body=body.slice(0,-1); @@ -82,15 +139,28 @@ async function loadFiles({open=false}={}){ if(open)document.querySelector("#files-dialog").showModal(); }catch(error){if(open)toast(error.message);} } -function connect(){socket?.stop();socket=new PadSocket({slug,password,accessToken,nickname,sessionToken:getAuthToken(),onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(/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;identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}}); +function connect(){socket?.stop();socket=new PadSocket({slug,password,accessToken,nickname,color:currentUserColor()||null,sessionToken:getAuthToken(),onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onPresence:updatePresence,onLatency:updateLatency,onChat:appendChatMessage,onError:m=>{document.querySelector("#password-error").textContent=m;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;identityDialog.close();updateCurrentUser();if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}}); identityDialog.addEventListener("close",()=>{if(!nickname)queueMicrotask(()=>{if(!identityDialog.open)identityDialog.showModal();});}); -async function initialize(){try{info=await api(`/api/pads/${encodeURIComponent(slug)}`);document.title=`${info.title} · RustPad`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`

    Note not found

    ${escapeHtml(e.message)}

    `;}} +async function initialize(){try{info=await api(`/api/pads/${encodeURIComponent(slug)}`);document.title=`${info.title} · RustPad`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}updateCurrentUser();if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`

    Note not found

    ${escapeHtml(e.message)}

    `;}} document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();}); -window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>{applyFormat(editor,b.dataset.format);b.closest("details")?.removeAttribute("open");}));bindFormatShortcuts(editor);document.querySelector("#shortcuts-button").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").showModal());document.querySelector("#close-shortcuts").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").close());preview.addEventListener("change",event=>{const checkbox=event.target.closest(".task-checkbox");if(!checkbox)return;const lineIndex=Number(checkbox.dataset.sourceLine)-1;const lines=editor.value.split("\n");if(lineIndex<0||lineIndex>=lines.length)return;lines[lineIndex]=lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/,`$1${checkbox.checked?"x":" "}$2`);editor.value=lines.join("\n");editor.dispatchEvent(new Event("input",{bubbles:true}));});preview.addEventListener("keydown",event=>{const target=event.target.closest(".preview-editable");if(!target)return;if(event.key==="Enter"){event.preventDefault();target.blur();}});preview.addEventListener("blur",event=>{const target=event.target.closest(".preview-editable");if(!target)return;const lineIndex=Number(target.dataset.sourceLine)-1;if(lineIndex<0)return;const lines=editor.value.split("\n");const 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}); +window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>{applyFormat(editor,b.dataset.format);b.closest("details")?.removeAttribute("open");}));bindFormatShortcuts(editor);document.querySelector("#shortcuts-button").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").showModal());document.querySelector("#close-shortcuts").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").close());preview.addEventListener("change",event=>{const checkbox=event.target.closest(".task-checkbox");if(!checkbox)return;const lineIndex=Number(checkbox.dataset.sourceLine)-1;const lines=editor.value.split("\n");if(lineIndex<0||lineIndex>=lines.length)return;lines[lineIndex]=lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/,`$1${checkbox.checked?"x":" "}$2`);editor.value=lines.join("\n");editor.dispatchEvent(new Event("input",{bubbles:true}));});preview.addEventListener("keydown",event=>{const target=event.target.closest(".preview-editable");if(!target)return;if(event.key==="Enter"){event.preventDefault();target.blur();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}); publicTaskUpdates.addEventListener("change",async()=>{publicTaskUpdates.disabled=true;try{await api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,allow_task_updates:publicTaskUpdates.checked})});toast(publicTaskUpdates.checked?"Public task updates enabled":"Public task updates disabled");}catch(error){publicTaskUpdates.checked=!publicTaskUpdates.checked;toast(error.message);}finally{publicTaskUpdates.disabled=false;}});document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,allow_task_updates:publicTaskUpdates.checked})});const url=new URL(result.url,location.origin).href;await copyText(url);toast("Page link copied");window.open(url,"_blank","noopener");}catch(error){toast(error.message);}}); -editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.lengthsocket?.update(editor.value,JSON.stringify(owners)),250);}); +roomDetails.addEventListener("toggle",()=>{if(roomDetails.open){clearUnread();chatInput.focus();if("Notification" in window&&Notification.permission==="default")Notification.requestPermission().catch(()=>{});}}); +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();}); +if(!chatMessages.children.length){const empty=document.createElement("p");empty.className="chat-empty";empty.textContent="No messages yet";chatMessages.append(empty);} +currentUser.addEventListener("click",()=>userColorPicker.click()); +userColorPicker.addEventListener("input",()=>{ + localStorage.setItem(storedColorKey(nickname),userColorPicker.value); + const replacement=currentOwner(); + owners=owners.map(owner=>ownerName(owner)===nickname?replacement:owner); + updateCurrentUser();render(); + socket?.setColor(userColorPicker.value); + if(socket)socket.update(editor.value,JSON.stringify(owners)); +}); +editor.addEventListener("keydown",continueIndentation);editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.lengthsocket?.update(editor.value,JSON.stringify(owners)),250);}); document.querySelector("#password-form").addEventListener("submit",async e=>{e.preventDefault();try{password=document.querySelector("#open-password").value;const result=await api("/api/access-token",{method:"POST",body:JSON.stringify({kind:"pad",slug,password})});accessToken=result.access_token;setAccessToken("pad",slug,accessToken);password="";document.querySelector("#open-password").value="";document.querySelector("#password-error").textContent="";connect();}catch(error){document.querySelector("#password-error").textContent=error.message;}}); const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='

    Loading…

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

    ${snippet}

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

    No history yet.

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

    ${escapeHtml(e.message)}

    `;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");}); document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("access_token",accessToken||"");form.append("file",file);try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");loadFiles();}catch(err){toast(err.message);}e.target.value="";}); diff --git a/static/js/socket.js b/static/js/socket.js index 4193537..f817493 100644 --- a/static/js/socket.js +++ b/static/js/socket.js @@ -1,13 +1,85 @@ -import { logDebug, logError, logInfo, logWarn } from "./logger.js"; +import { logError, logInfo, logWarn } from "./logger.js"; -export class NoteSocket { - constructor({ workspaceSlug, noteSlug, password, accessToken, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }) { Object.assign(this, { workspaceSlug, noteSlug, password, accessToken, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }); this.socket=null; this.timer=null; this.closed=false; } - connect() { clearTimeout(this.timer); this.closed=false; this.onStatus?.("connecting"); const protocol=location.protocol==="https:"?"wss:":"ws:"; this.socket=new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`); this.socket.addEventListener("open",()=>{logInfo("websocket.open",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,access_token:this.accessToken||null,nickname:this.nickname||null,session_token:this.sessionToken||null}));}); this.socket.addEventListener("message",event=>{const m=JSON.parse(event.data); if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();} if(m.type==="authenticated"){logInfo("websocket.authenticated",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.onStatus?.("online");this.onAuthenticated?.(m);} if(m.type==="document")this.onDocument?.(m);}); this.socket.addEventListener("close",event=>{logWarn("websocket.close",{kind:"note",code:event.code,reason:event.reason||"",intentional:this.closed});if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}}); this.socket.addEventListener("error",event=>{logError("websocket.error",event,{kind:"note"});this.onError?.("Failed to connect to the WebSocket server");this.socket.close();}); } - update(content, ownerMap="[]") { if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap})); } - stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();} +class RoomSocket { + constructor(options) { + Object.assign(this, options); + this.socket = null; + this.timer = null; + this.pingTimer = null; + this.closed = false; + this.pendingPings = new Map(); + } + get url() { throw new Error("Socket URL not implemented"); } + get kind() { return "room"; } + connect() { + clearTimeout(this.timer); + clearInterval(this.pingTimer); + this.closed = false; + this.onStatus?.("connecting"); + this.socket = new WebSocket(this.url); + this.socket.addEventListener("open", () => { + logInfo("websocket.open", { kind: this.kind }); + this.send({ type: "authenticate", password: this.password || null, access_token: this.accessToken || null, nickname: this.nickname || null, session_token: this.sessionToken || null, color: this.color || null }); + }); + this.socket.addEventListener("message", event => { + let message; + try { message = JSON.parse(event.data); } catch { return; } + if (message.type === "error") { this.onError?.(message.message); this.closed = true; this.socket.close(); return; } + if (message.type === "authenticated") { + this.onStatus?.("online"); + this.onAuthenticated?.(message); + this.startPing(); + return; + } + if (message.type === "document") this.onDocument?.(message); + if (message.type === "presence") this.onPresence?.(message.users || []); + if (message.type === "chat") this.onChat?.(message); + if (message.type === "pong") { + const started = this.pendingPings.get(message.nonce); + if (started !== undefined) { + this.pendingPings.delete(message.nonce); + this.onLatency?.(Math.max(0, Math.round(performance.now() - started))); + } + } + }); + this.socket.addEventListener("close", event => { + clearInterval(this.pingTimer); + this.pendingPings.clear(); + this.onPresence?.([]); + this.onLatency?.(null); + logWarn("websocket.close", { kind: this.kind, code: event.code, reason: event.reason || "", intentional: this.closed }); + if (!this.closed) { this.onStatus?.("offline"); this.timer = setTimeout(() => this.connect(), 1500); } + }); + this.socket.addEventListener("error", event => { + logError("websocket.error", event, { kind: this.kind }); + this.onError?.("Failed to connect to the WebSocket server"); + this.socket.close(); + }); + } + startPing() { + clearInterval(this.pingTimer); + const ping = () => { + if (this.socket?.readyState !== WebSocket.OPEN) return; + const nonce = Date.now(); + this.pendingPings.set(nonce, performance.now()); + for (const key of this.pendingPings.keys()) if (key < nonce - 30000) this.pendingPings.delete(key); + this.send({ type: "ping", nonce }); + }; + ping(); + this.pingTimer = setInterval(ping, 10000); + } + send(message) { if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message)); } + update(content, ownerMap = "[]") { this.send({ type: "update", content, owner_map: ownerMap }); } + chat(text) { this.send({ type: "chat", text }); } + setColor(color) { this.color = color || null; this.send({ type: "set_color", color: this.color }); } + stop() { this.closed = true; clearTimeout(this.timer); clearInterval(this.pingTimer); this.socket?.close(); } } -export class PadSocket { - constructor({slug,password,accessToken,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError}){Object.assign(this,{slug,password,accessToken,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError});this.socket=null;this.timer=null;this.closed=false;} - connect(){clearTimeout(this.timer);this.closed=false;this.onStatus?.("connecting");const protocol=location.protocol==="https:"?"wss:":"ws:";this.socket=new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`);this.socket.addEventListener("open",()=>{logInfo("websocket.open",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,access_token:this.accessToken||null,nickname:this.nickname||null,session_token:this.sessionToken||null}));});this.socket.addEventListener("message",e=>{const m=JSON.parse(e.data);if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();}if(m.type==="authenticated"){logInfo("websocket.authenticated",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.onStatus?.("online");this.onAuthenticated?.(m);}if(m.type==="document")this.onDocument?.(m);});this.socket.addEventListener("close",event=>{logWarn("websocket.close",{kind:"note",code:event.code,reason:event.reason||"",intentional:this.closed});if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}});this.socket.addEventListener("error",event=>{logError("websocket.error",event,{kind:"note"});this.onError?.("Failed to connect to the WebSocket server");this.socket.close();});} - update(content,ownerMap="[]"){if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap}));} stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();} + +export class NoteSocket extends RoomSocket { + get kind() { return "note"; } + get url() { const p = location.protocol === "https:" ? "wss:" : "ws:"; return `${p}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`; } +} +export class PadSocket extends RoomSocket { + get kind() { return "pad"; } + get url() { const p = location.protocol === "https:" ? "wss:" : "ws:"; return `${p}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`; } } diff --git a/static/note.html b/static/note.html index 19a78ba..2847288 100644 --- a/static/note.html +++ b/static/note.html @@ -1,5 +1,5 @@ __NOTE_TITLE__ · RustPad -
    __WORKSPACE_TITLE__

    __NOTE_TITLE__

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

    __NOTE_TITLE__

    +
    More
    Editor
    Markdown preview
    · · Changes are saved automatically

    Keyboard shortcuts

    Use Ctrl on Windows/Linux or Cmd on macOS.

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

    Note files

    Copy a direct link or ready Markdown/HTML code.

    What should we call you?

    Use a free nickname without an account, or register it to reserve it.

    Protected workspace

    Back
    diff --git a/static/pad.html b/static/pad.html index 3d03bda..7555b15 100644 --- a/static/pad.html +++ b/static/pad.html @@ -1,5 +1,5 @@ __PAD_TITLE__ · RustPad -
    RustPad

    __PAD_TITLE__

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

    __PAD_TITLE__

    +
    More
    Editor
    Markdown preview
    · · Changes are saved automatically

    Keyboard shortcuts

    Use Ctrl on Windows/Linux or Cmd on macOS.

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

    Note files

    Copy a direct link or ready Markdown/HTML code.

    What should we call you?

    Use a free nickname without an account, or register it to reserve it.

    Protected note

    Back
${inline(cell)}
${inline(cells[i] || "")}${inline(cells[i] || "")}