fixes and features
This commit is contained in:
@@ -10,3 +10,4 @@ rustpad.db-wal
|
||||
README.md
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
migrate/
|
||||
@@ -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
|
||||
|
||||
@@ -10,3 +10,6 @@ data/db/.db*
|
||||
data/db/*/*
|
||||
data/files/*
|
||||
*.zip
|
||||
venv
|
||||
.venv
|
||||
migrate/etherpad-dry-run-report.json
|
||||
Generated
+1
-1
@@ -1408,7 +1408,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.0.4"
|
||||
version = "0.0.8"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
mysql-connector-python>=9.0,<10.0
|
||||
+8
-4
@@ -12,7 +12,7 @@ use slug::slugify;
|
||||
|
||||
use crate::{
|
||||
db, queries,
|
||||
state::{NoteUpdate, SharedState},
|
||||
state::{NoteUpdate, RoomEvent, SharedState},
|
||||
};
|
||||
|
||||
const MAX_NAME_LENGTH: usize = 80;
|
||||
@@ -282,7 +282,7 @@ pub async fn restore(
|
||||
author: Some("restore".into()),
|
||||
owner_map: "[]".into(),
|
||||
};
|
||||
let _ = state.note_channel(&workspace_slug, ¬e_slug).await.send(update);
|
||||
let _ = state.note_channel(&workspace_slug, ¬e_slug).await.send(RoomEvent::Document(update));
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
@@ -553,7 +553,7 @@ pub async fn pad_restore(
|
||||
author: Some("restore".into()),
|
||||
owner_map,
|
||||
};
|
||||
let _ = state.pad_channel(&slug).await.send(update);
|
||||
let _ = state.pad_channel(&slug).await.send(RoomEvent::Document(update));
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
@@ -800,7 +800,11 @@ async fn serve_token_file(state: &SharedState, token: &str, filename: &str) -> R
|
||||
HeaderValue::from_str(mime.as_ref()).unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
|
||||
);
|
||||
response.headers_mut().insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=600"));
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_str(&format!("public, max-age={}", state.file_cache_max_age_seconds))
|
||||
.expect("valid file cache-control header"),
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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()?)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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, ¬e_slug).await.ok().flatten() else { warn!(%workspace_slug, %note_slug, "note websocket rejected: note not found"); let _=send_error(&mut socket,"Note not found").await; return; };
|
||||
let (password, access_token, nickname, session_token) = match socket.recv().await {
|
||||
let (password, access_token, nickname, session_token, color) = match socket.recv().await {
|
||||
Some(Ok(Message::Text(text))) => match serde_json::from_str::<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, ¬e_slug);
|
||||
let channel=state.note_channel(&workspace_slug,¬e_slug).await;
|
||||
let mut updates=channel.subscribe();
|
||||
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
|
||||
let (connection_id, users) = state.join_room(&room_key, display_name.clone(), color).await;
|
||||
let _ = channel.send(RoomEvent::Presence(users));
|
||||
let mut last_chat = Instant::now() - Duration::from_secs(1);
|
||||
let (mut sender,mut receiver)=socket.split();
|
||||
loop { tokio::select! {
|
||||
incoming=receiver.next()=>match incoming {
|
||||
@@ -47,25 +59,41 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug
|
||||
if content.len()>2_000_000 { let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await; continue; }
|
||||
let owner_map=owner_map.unwrap_or_else(||"[]".into());
|
||||
match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await {
|
||||
Ok((revision_id,updated_at))=>{let _=channel.send(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map});}
|
||||
Ok((revision_id,updated_at))=>{let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}));}
|
||||
Err(error)=>warn!(%error, workspace_id = workspace.id, note_id = note.id, "failed to save revision"),
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::Ping{nonce})=>{ let _=send_split(&mut sender,&ServerMessage::Pong{nonce}).await; },
|
||||
Ok(ClientMessage::Chat{text})=>{
|
||||
let text=clean_chat(text);
|
||||
if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); }
|
||||
}
|
||||
Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); },
|
||||
Ok(ClientMessage::Authenticate{..})=>{}, Err(error)=>warn!(%error,"invalid websocket message"),
|
||||
},
|
||||
Some(Ok(Message::Close(_)))|None=>break, Some(Ok(_))=>{}, Some(Err(error))=>{debug!(%error,"websocket receive error");break;}
|
||||
},
|
||||
update=updates.recv()=>match update {
|
||||
Ok(update)=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;},
|
||||
Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;},
|
||||
Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;},
|
||||
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,¬e_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
|
||||
}
|
||||
}}
|
||||
let users = state.leave_room(&room_key, connection_id).await;
|
||||
let _ = channel.send(RoomEvent::Presence(users));
|
||||
info!(workspace_id = workspace.id, note_id = note.id, "note websocket disconnected");
|
||||
}
|
||||
fn clean_nickname(value: Option<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
@@ -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); }
|
||||
|
||||
@@ -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
@@ -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
@@ -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})`;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
@@ -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})`;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
@@ -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
@@ -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 H1–H4</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
@@ -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 H1–H4</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>
|
||||
|
||||
Reference in New Issue
Block a user