fixes and features

This commit is contained in:
Mateusz Gruszczyński
2026-07-23 13:47:17 +02:00
parent 3ed74b1eac
commit dc6227896b
21 changed files with 831 additions and 82 deletions
+1
View File
@@ -10,3 +10,4 @@ rustpad.db-wal
README.md
Dockerfile*
docker-compose*.yml
migrate/
+4
View File
@@ -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
+3
View File
@@ -10,3 +10,6 @@ data/db/.db*
data/db/*/*
data/files/*
*.zip
venv
.venv
migrate/etherpad-dry-run-report.json
Generated
+1 -1
View File
@@ -1408,7 +1408,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.0.4"
version = "0.0.8"
dependencies = [
"argon2",
"axum",
+1 -1
View File
@@ -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"
+328
View File
@@ -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:<id>.
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())
+1
View File
@@ -0,0 +1 @@
mysql-connector-python>=9.0,<10.0
+8 -4
View File
@@ -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, &note_slug).await.send(update);
let _ = state.note_channel(&workspace_slug, &note_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)
}
+5 -2
View File
@@ -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)),
)
+8
View File
@@ -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<crate::state::SmtpConfig>,
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<i64, Box<dyn std::error:
}
Ok(value)
}
fn env_nonnegative_u64(name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
Ok(env_var(name, &default.to_string()).parse()?)
}
+4
View File
@@ -31,6 +31,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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?;
+54 -9
View File
@@ -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<String>,
}
#[derive(Debug, Clone)]
pub enum RoomEvent {
Document(NoteUpdate),
Presence(Vec<PresenceUser>),
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<SmtpConfig>,
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<HashMap<String, broadcast::Sender<NoteUpdate>>>,
channels: RwLock<HashMap<String, broadcast::Sender<RoomEvent>>>,
presence: RwLock<HashMap<String, HashMap<u64, PresenceUser>>>,
next_connection_id: AtomicU64,
}
impl AppState {
pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, smtp: Option<SmtpConfig>, 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<SmtpConfig>, 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<NoteUpdate> {
async fn channel_for_key(&self, key: String) -> broadcast::Sender<RoomEvent> {
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<NoteUpdate> {
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<RoomEvent> { self.channel_for_key(Self::note_room_key(workspace_slug, note_slug)).await }
pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender<RoomEvent> { self.channel_for_key(Self::pad_room_key(slug)).await }
pub async fn join_room(&self, key: &str, nickname: String, color: Option<String>) -> (u64, Vec<PresenceUser>) {
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<NoteUpdate> {
self.channel_for_key(format!("pad:{slug}")).await
pub async fn update_room_color(&self, key: &str, id: u64, color: Option<String>) -> Vec<PresenceUser> {
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<PresenceUser> {
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<u64, PresenceUser>) -> Vec<PresenceUser> {
let mut users: Vec<PresenceUser> = room.values().cloned().collect();
users.sort_by_key(|value| value.name.to_lowercase());
users
}
pub type SharedState = Arc<AppState>;
+56 -10
View File
@@ -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<String>, access_token: Option<String>, nickname: Option<String>, session_token: Option<String> },
Authenticate { password: Option<String>, access_token: Option<String>, nickname: Option<String>, session_token: Option<String>, color: Option<String> },
Update { content: String, owner_map: Option<String> },
Ping { nonce: u64 },
Chat { text: String },
SetColor { color: Option<String> },
}
#[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<String>, owner_map: String },
Presence { users: Vec<PresenceUser> },
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, &note_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::<ClientMessage>(&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, &note_slug);
let channel=state.note_channel(&workspace_slug,&note_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,&note_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<String>)->Option<String> {
value.map(|v|v.trim().chars().take(40).collect::<String>()).filter(|v|!v.is_empty())
}
fn clean_color(value: Option<String>) -> Option<String> {
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::<String>().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<WebSocket,Messag
enum PadServerMessage {
Authenticated { title: String, content: String, owner_map: String },
Document { content: String, revision_id: i64, updated_at: String, author: Option<String>, owner_map: String },
Presence { users: Vec<PresenceUser> },
Chat { sender: String, text: String },
Pong { nonce: u64 },
Error { message: String },
}
pub async fn upgrade_pad(ws:WebSocketUpgrade,Path(slug):Path<String>,State(state):State<SharedState>)->Response{
@@ -92,9 +123,9 @@ pub async fn upgrade_pad(ws:WebSocketUpgrade,Path(slug):Path<String>,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::<ClientMessage>(&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> {
+69 -3
View File
@@ -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); }
+1
View File
@@ -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, "<details>\n<summary>Click me</summary>\n\n", "\n</details>", "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");
+43 -20
View File
@@ -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"
? `<div class="mermaid preview-source-line" data-source-line="${codeStart + 1}">${body}</div>`
: `<pre${attrs(codeStart)}><code class="language-${escapeHtml(language)}">${body}</code></pre>`;
? `<div class="mermaid preview-source-line" data-source-line="${codeStart + lineOffset + 1}">${body}</div>`
: `<pre${attrs(codeStart, false, "", "", lineOffset)}><code class="language-${escapeHtml(language)}">${body}</code></pre>`;
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 += `<div class="table-wrap preview-source-line" data-source-line="${index + 1}"><table><thead><tr>`;
headers.forEach((cell, i) => html += `<th class="preview-editable" contenteditable="true" spellcheck="true" data-source-line="${index + 1}" data-table-cell="${i}" style="text-align:${delimiter[i] || "left"}">${inline(cell)}</th>`);
html += `<div class="table-wrap preview-source-line" data-source-line="${index + lineOffset + 1}"><table><thead><tr>`;
headers.forEach((cell, i) => html += `<th class="preview-editable" contenteditable="true" spellcheck="true" data-source-line="${index + lineOffset + 1}" data-table-cell="${i}" style="text-align:${delimiter[i] || "left"}">${inline(cell)}</th>`);
html += `</tr></thead><tbody>`;
index += 2;
while (index < lines.length && lines[index].includes("|") && lines[index].trim()) {
const cells = splitTableRow(lines[index]);
html += `<tr>`;
headers.forEach((_, i) => html += `<td class="preview-editable" contenteditable="true" spellcheck="true" data-source-line="${index + 1}" data-table-cell="${i}" style="text-align:${delimiter[i] || "left"}">${inline(cells[i] || "")}</td>`);
headers.forEach((_, i) => html += `<td class="preview-editable" contenteditable="true" spellcheck="true" data-source-line="${index + lineOffset + 1}" data-table-cell="${i}" style="text-align:${delimiter[i] || "left"}">${inline(cells[i] || "")}</td>`);
html += `</tr>`;
index++;
}
@@ -130,39 +130,62 @@ export function renderMarkdown(source) {
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+?)(?:\s+\{#([A-Za-z][\w:.-]*)\})?\s*$/);
if (/^<details>\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(/^<summary>([\s\S]*?)<\/summary>$/i);
if (summaryMatch) { summary = summaryMatch[1].trim() || "Details"; block.shift(); }
}
while (block.length && !block[0].trim()) block.shift();
html += `<details class="markdown-details preview-source-line" data-source-line="${index + lineOffset + 1}"><summary>${inline(summary)}</summary><div class="markdown-details__content">${renderMarkdown(block.join("\n"), lineOffset + index + 1)}</div></details>`;
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 += `<h${n}${id}${attrs(index, true, `${heading[1]} `, suffix)}>${inline(heading[2])}</h${n}>`;
html += `<h${n}${id}${attrs(index, true, `${heading[1]} `, suffix, lineOffset)}>${inline(heading[2])}</h${n}>`;
} else if (task) {
if (list !== "ul") { closeList(); html += `<ul class="task-list">`; list = "ul"; }
const checked = task[2].toLowerCase() === "x";
html += `<li class="preview-source-line task-list-item" data-source-line="${index + 1}"><input type="checkbox" class="task-checkbox" data-source-line="${index + 1}"${checked ? " checked" : ""}><span class="preview-editable" contenteditable="true" spellcheck="true" data-source-line="${index + 1}" data-source-prefix="${escapeHtml(`${task[1]}- [${checked ? "x" : " "}] `)}">${inline(task[3])}</span></li>`;
html += `<li class="preview-source-line task-list-item" data-source-line="${index + lineOffset + 1}"><input type="checkbox" class="task-checkbox" data-source-line="${index + lineOffset + 1}"${checked ? " checked" : ""}><span class="preview-editable" contenteditable="true" spellcheck="true" data-source-line="${index + lineOffset + 1}" data-source-prefix="${escapeHtml(`${task[1]}- [${checked ? "x" : " "}] `)}">${inline(task[3])}</span></li>`;
} else if (ul || ol) {
const type = ul ? "ul" : "ol";
if (list !== type) { closeList(); html += `<${type}>`; list = type; }
html += `<li${attrs(index, true, ul ? "- " : `${(line.match(/^\s*(\d+)\./)||[])[1] || 1}. `)}>${inline((ul || ol)[1])}</li>`;
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 += `<li${attrs(index, true, prefix, "", lineOffset)}${indentStyle}>${inline(match[2])}</li>`;
} else {
closeList();
const definition = index + 1 < lines.length && /^:\s+/.test(lines[index + 1]);
if (line.trim() && definition) {
html += `<dl${attrs(index)}><dt>${inline(line)}</dt>`;
html += `<dl${attrs(index, false, "", "", lineOffset)}><dt>${inline(line)}</dt>`;
while (index + 1 < lines.length && /^:\s+/.test(lines[index + 1])) {
index++;
html += `<dd data-source-line="${index + 1}">${inline(lines[index].replace(/^:\s+/, ""))}</dd>`;
html += `<dd data-source-line="${index + lineOffset + 1}">${inline(lines[index].replace(/^:\s+/, ""))}</dd>`;
}
html += `</dl>`;
} else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index)}>`;
else if (line.startsWith("> ")) html += `<blockquote${attrs(index, true, "> ")}>${inline(line.slice(2))}</blockquote>`;
else if (line.trim()) html += `<p${attrs(index, true)}>${inline(line)}</p>`;
else html += `<div${attrs(index, true)}><br></div>`;
} else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset)}>`;
else if (line.startsWith("> ")) html += `<blockquote${attrs(index, true, "> ", "", lineOffset)}>${inline(line.slice(2))}</blockquote>`;
else if (line.trim()) html += `<p${attrs(index, true, "", "", lineOffset)}>${inline(line)}</p>`;
else html += `<div${attrs(index, true, "", "", lineOffset)}><br></div>`;
}
}
+79 -9
View File
@@ -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(index<text.length)container.append(document.createTextNode(text.slice(index)));}
function appendChatMessage(message){const empty=chatMessages.querySelector(".chat-empty");empty?.remove();const row=document.createElement("p");row.className="chat-message";const author=document.createElement("strong");author.textContent=message.sender;const text=document.createElement("span");appendLinkifiedText(text,message.text);row.append(author,text);chatMessages.append(row);while(chatMessages.children.length>100)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",'<p class="error">Failed to load Mermaid.</p>'));}}
@@ -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<lineCount)owners.push(owners.at(-1)||nickname||"");
while(owners.length<lineCount)owners.push(owners.at(-1)||currentOwner()||"");
const style=getComputedStyle(editor), lineHeight=parseFloat(style.lineHeight)||29, paddingTop=parseFloat(style.paddingTop)||24, paddingBottom=parseFloat(style.paddingBottom)||24;
gutter.style.paddingTop=`${paddingTop}px`;gutter.style.paddingBottom=`${paddingBottom}px`;gutter.style.lineHeight=`${lineHeight}px`;
gutter.innerHTML=lines.map((_,i)=>`<div style="height:${lineHeight}px">${i+1}</div>`).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]?`<span class="owner-label" style="top:${top}px;--owner:${colorFor(owner)}">${escapeHtml(owner)}</span>`:"";
const label=owner!==owners[i-1]?`<span class="owner-label" style="top:${top}px;--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`:"";
return `<span class="owner-line" style="top:${top}px;--owner:${colorFor(owner)}"></span>${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)=>`<div class="preview-source-line preview-editable" data-source-line="${index+1}" contenteditable="true" spellcheck="true">${escapeHtml(line)||"<br>"}</div>`).join("");document.querySelector("#preview-label").textContent="Text preview · editable";}document.querySelector("#characters").textContent=`${editor.value.length} characters`;document.querySelector("#words").textContent=`${editor.value.trim()?editor.value.trim().split(/\s+/).length:0} words`;renderGutter();}
function applyUi({write=false,replace=false}={}){editorWorkspace.className=`workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`;editorWorkspace.style.setProperty("--editor-font-size",`${fontSize.value}px`);document.body.classList.toggle("compact-editor",compactToggle.checked);document.querySelectorAll("[data-view]").forEach(b=>{const a=b.dataset.view===uiState.view;b.classList.toggle("active",a);b.setAttribute("aria-pressed",String(a));});const markdown=uiState.mode==="markdown";modeToggle.classList.toggle("active",markdown);modeToggle.textContent=markdown?"Markdown":"Text";render();if(write)writeEditorState(uiState,{replace});updateAddressLabel();}
function 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=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}updateCurrentUser();document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
document.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.length<newLines)owners.push(nickname);owners=owners.slice(0,newLines);owners[cursorLine]=nickname;render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
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.length<newLines)owners.push(currentOwner());owners=owners.slice(0,newLines);owners[cursorLine]=currentOwner();render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
document.querySelector("#password-form").addEventListener("submit",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='<p class="empty">Loading…</p>';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 `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`;}).join(""):'<p class="empty">No history yet.</p>';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`<p class="error">${escapeHtml(e.message)}</p>`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");});
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="";});
+79 -9
View File
@@ -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(index<text.length)container.append(document.createTextNode(text.slice(index)));}
function appendChatMessage(message){const empty=chatMessages.querySelector(".chat-empty");empty?.remove();const row=document.createElement("p");row.className="chat-message";const author=document.createElement("strong");author.textContent=message.sender;const text=document.createElement("span");appendLinkifiedText(text,message.text);row.append(author,text);chatMessages.append(row);while(chatMessages.children.length>100)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",'<p class="error">Failed to load Mermaid.</p>'));}}
@@ -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<lineCount)owners.push(owners.at(-1)||nickname||"");
while(owners.length<lineCount)owners.push(owners.at(-1)||currentOwner()||"");
const style=getComputedStyle(editor), lineHeight=parseFloat(style.lineHeight)||29, paddingTop=parseFloat(style.paddingTop)||24, paddingBottom=parseFloat(style.paddingBottom)||24;
gutter.style.paddingTop=`${paddingTop}px`;gutter.style.paddingBottom=`${paddingBottom}px`;gutter.style.lineHeight=`${lineHeight}px`;
gutter.innerHTML=lines.map((_,i)=>`<div style="height:${lineHeight}px">${i+1}</div>`).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]?`<span class="owner-label" style="top:${top}px;--owner:${colorFor(owner)}">${escapeHtml(owner)}</span>`:"";
const label=owner!==owners[i-1]?`<span class="owner-label" style="top:${top}px;--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`:"";
return `<span class="owner-line" style="top:${top}px;--owner:${colorFor(owner)}"></span>${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=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
async function initialize(){try{info=await api(`/api/pads/${encodeURIComponent(slug)}`);document.title=`${info.title} · RustPad`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}updateCurrentUser();if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
document.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.length<newLines)owners.push(nickname);owners=owners.slice(0,newLines);owners[cursorLine]=nickname;render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
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.length<newLines)owners.push(currentOwner());owners=owners.slice(0,newLines);owners[cursorLine]=currentOwner();render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
document.querySelector("#password-form").addEventListener("submit",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='<p class="empty">Loading…</p>';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 `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`;}).join(""):'<p class="empty">No history yet.</p>';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/pads/${encodeURIComponent(slug)}/restore`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`<p class="error">${escapeHtml(e.message)}</p>`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");});
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="";});
+82 -10
View File
@@ -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)}`; }
}
+2 -2
View File
@@ -1,5 +1,5 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__NOTE_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/note.js?v=__ASSET_VERSION__"></script></head>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__"><header class="app-header"><div class="app-header__main"><a id="workspace-link" class="brand" href="/w/__WORKSPACE_SLUG__">__WORKSPACE_TITLE__</a><span class="header-divider"></span><div class="document-heading"><h1 id="note-title">__NOTE_TITLE__</h1><p id="note-url" class="document-url"></p></div></div><div class="header-actions"><span id="current-user" class="user-chip"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><label class="public-task-toggle" title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates" type="checkbox"> Editable tasks on Page</label><button id="files-button" class="secondary-button">Files</button><button id="delete-note" class="secondary-button danger-button" hidden>Delete</button><button id="history-button" class="secondary-button">History</button></div></header>
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__"><header class="app-header"><div class="app-header__main"><a id="workspace-link" class="brand" href="/w/__WORKSPACE_SLUG__">__WORKSPACE_TITLE__</a><span class="header-divider"></span><div class="document-heading"><h1 id="note-title">__NOTE_TITLE__</h1><p id="note-url" class="document-url"></p></div></div><div class="header-actions"><span class="user-color-control"><button id="current-user" class="user-chip" type="button" title="Change your color"><span class="user-chip__dot" aria-hidden="true"></span><span class="user-chip__name"></span></button><input id="user-color-picker" class="user-color-picker" type="color" aria-label="Choose your color"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><label class="public-task-toggle" title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates" type="checkbox"> Editable tasks on Page</label><button id="files-button" class="secondary-button">Files</button><button id="delete-note" class="secondary-button danger-button" hidden>Delete</button><button id="history-button" class="secondary-button">History</button></div></header>
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="details">Collapsible section</button><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span> · <span id="socket-latency" title="WebSocket round-trip time">— ms</span> · <details id="room-details" class="room-details"><summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread" hidden></span></summary><div class="room-popover"><section class="room-users"><strong>In this room</strong><ul id="room-users"></ul></section><section class="room-chat"><div class="room-chat__head"><strong>Room chat</strong><span>Messages disappear after disconnect</span></div><div id="chat-messages" class="chat-messages" aria-live="polite"></div><form id="chat-form" class="chat-form"><input id="chat-input" maxlength="1000" autocomplete="off" placeholder="Write a message…" aria-label="Chat message"><button type="submit">Send</button></form></section></div></details></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity" class="modal-close" type="button" aria-label="Close dialog">×</button><h2>What should we call you?</h2><p class="dialog-copy">Use a free nickname without an account, or register it to reserve it.</p><input id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required placeholder="Name or nickname"><div class="identity-actions"><button id="guest-continue" class="primary-button" type="submit">Continue as guest</button><button id="show-register" class="text-button" type="button">Register</button><button id="show-login" class="text-button" type="button">Log in</button></div><section id="auth-panel" class="auth-panel" hidden><h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button id="auth-submit" class="primary-button" type="submit">Log in and continue</button><div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot password?</button><button id="auth-back" class="text-button" type="button">Back to nickname</button><button id="logout-account" class="text-button" type="button">Log out saved account</button></div></section><p id="identity-error" class="form-message error" role="alert"></p></form></dialog>
<dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a id="back-workspace" class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html>
+2 -2
View File
@@ -1,5 +1,5 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__PAD_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/pad.js?v=__ASSET_VERSION__"></script></head>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__"><header class="app-header"><div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span><div class="document-heading"><h1 id="pad-title">__PAD_TITLE__</h1><p id="pad-url" class="document-url"></p></div></div><div class="header-actions"><span id="current-user" class="user-chip"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><label class="public-task-toggle" title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates" type="checkbox"> Editable tasks on Page</label><button id="files-button" class="secondary-button">Files</button><button id="history-button" class="secondary-button">History</button></div></header>
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__"><header class="app-header"><div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span><div class="document-heading"><h1 id="pad-title">__PAD_TITLE__</h1><p id="pad-url" class="document-url"></p></div></div><div class="header-actions"><span class="user-color-control"><button id="current-user" class="user-chip" type="button" title="Change your color"><span class="user-chip__dot" aria-hidden="true"></span><span class="user-chip__name"></span></button><input id="user-color-picker" class="user-color-picker" type="color" aria-label="Choose your color"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><label class="public-task-toggle" title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates" type="checkbox"> Editable tasks on Page</label><button id="files-button" class="secondary-button">Files</button><button id="history-button" class="secondary-button">History</button></div></header>
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="details">Collapsible section</button><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span> · <span id="socket-latency" title="WebSocket round-trip time">— ms</span> · <details id="room-details" class="room-details"><summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread" hidden></span></summary><div class="room-popover"><section class="room-users"><strong>In this room</strong><ul id="room-users"></ul></section><section class="room-chat"><div class="room-chat__head"><strong>Room chat</strong><span>Messages disappear after disconnect</span></div><div id="chat-messages" class="chat-messages" aria-live="polite"></div><form id="chat-form" class="chat-form"><input id="chat-input" maxlength="1000" autocomplete="off" placeholder="Write a message…" aria-label="Chat message"><button type="submit">Send</button></form></section></div></details></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity" class="modal-close" type="button" aria-label="Close dialog">×</button><h2>What should we call you?</h2><p class="dialog-copy">Use a free nickname without an account, or register it to reserve it.</p><input id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required placeholder="Name or nickname"><div class="identity-actions"><button id="guest-continue" class="primary-button" type="submit">Continue as guest</button><button id="show-register" class="text-button" type="button">Register</button><button id="show-login" class="text-button" type="button">Log in</button></div><section id="auth-panel" class="auth-panel" hidden><h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button id="auth-submit" class="primary-button" type="submit">Log in and continue</button><div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot password?</button><button id="auth-back" class="text-button" type="button">Back to nickname</button><button id="logout-account" class="text-button" type="button">Log out saved account</button></div></section><p id="identity-error" class="form-message error" role="alert"></p></form></dialog>
<dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected note</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html>