new functions and fixes

This commit is contained in:
Mateusz Gruszczyński
2026-08-05 22:13:04 +02:00
parent 9fc32d0d31
commit e842b26978
34 changed files with 2505 additions and 115 deletions
+5 -1
View File
@@ -12,4 +12,8 @@ Dockerfile*
docker-compose*.yml
migrate/
scripts/*.txt
tests/
tests/
# Downloaded in the browser-libs Docker stage
static/libs/mermaid/
static/libs/highlight/
+5 -1
View File
@@ -14,4 +14,8 @@ venv
.venv
migrate/etherpad-dry-run-report.json
data/garage
scripts/*.txt
scripts/*.txt
# Generated by scripts/update_browser_libs.py
/static/libs/mermaid/
/static/libs/highlight/
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.40"
version = "0.2.42"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
version = "0.2.40"
version = "0.2.42"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+10
View File
@@ -1,3 +1,11 @@
FROM python:3.14-slim AS browser-libs
WORKDIR /app
ARG BROWSER_LIBS_REFRESH=manual
COPY scripts/update_browser_libs.py ./scripts/update_browser_libs.py
RUN echo "Browser library refresh: ${BROWSER_LIBS_REFRESH}" \
&& python3 ./scripts/update_browser_libs.py --root /app --strict
FROM rust:slim-trixie AS builder
WORKDIR /app
@@ -5,6 +13,8 @@ COPY Cargo.toml Cargo.lock ./
COPY migrations ./migrations
COPY src ./src
COPY static ./static
COPY --from=browser-libs /app/static/libs/mermaid ./static/libs/mermaid
COPY --from=browser-libs /app/static/libs/highlight ./static/libs/highlight
RUN cargo build --release
+17 -3
View File
@@ -8,7 +8,7 @@ RustPad is a collaborative Markdown editor with standalone notes and workspaces.
./dev.sh
```
The script creates `data/db` and `data/files`, builds the project, and starts it with Cargo. If Cargo is unavailable, it runs `docker compose up --build` instead.
The script creates `data/db` and `data/files`, refreshes the generated browser libraries, builds the project, and starts it with Cargo. If Cargo is unavailable, it runs `docker compose up --build` instead; the Docker build downloads the libraries in a separate stage.
## Workspace features
@@ -16,10 +16,12 @@ The script creates `data/db` and `data/files`, builds the project, and starts it
- Nicknames stored in `localStorage`.
- Change authors shown in history.
- Line numbering enabled by default, with per-account preferences stored separately for each note or pad.
- The formatting toolbar can be collapsed; the state is saved per account and per note or pad.
- Signed-in users with read/write access can save personal compact view, line, font, size, authorship, and color preferences; resource-linked rows are removed with the note, pad, or account.
- Owner color displayed next to each line.
- Image and file uploads to `data/files/pads/<id>_<token>/` or `data/files/notes/<id>_<token>/`.
- Compact attachment aliases are inserted after upload: `[file=name.ext,label]` and `[image=name.ext,alt]`. The file dialog also provides standard Markdown for compatibility.
- Compact attachment aliases are inserted after upload: `[file=name.ext,label]`, `[image=name.ext,alt]`, and `[video=name.ext,label]`. Video uploads can be inserted as an embedded player or a forced-download link.
- Standalone YouTube links are rendered as responsive privacy-enhanced players.
- Markdown and Mermaid diagram rendering.
- History with snippets, previews, and version restore.
- Alert blocks: `success`, `info`, `warning`, and `danger`.
@@ -80,7 +82,7 @@ In Docker, both directories are located under `/data`.
## Publishing a note as a page
Use the **Page** button in the editor. RustPad creates a permanent public `/s/<token>` URL, copies it to the clipboard, and opens it in a new tab. The page displays the current note and renders Markdown, images, links, and Mermaid diagrams.
Use the **Page** button in the editor. RustPad creates a permanent public `/s/<token>` URL, copies it to the clipboard, and opens it in a new tab. The page displays the current note and renders Markdown, images, video players, YouTube embeds, links, and Mermaid diagrams.
Publishing a protected note requires its password, but the generated public page itself is accessible without that password.
@@ -131,6 +133,18 @@ Nicknames can be used anonymously while they remain unregistered. Registering a
Configure `PUBLIC_URL`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURITY`, `SMTP_USERNAME`, `SMTP_PASSWORD`, and `SMTP_FROM` to enable password-reset emails. `SMTP_FROM` accepts both `RustPad <no-reply@example.com>` and a value wrapped in one matching pair of single or double quotes, as may be passed literally by container env-file implementations. `SMTP_SECURITY` accepts `none` (plain SMTP, typically an internal relay on port 25), `starttls`, or `tls` (implicit TLS, commonly port 465). When omitted, it defaults to `tls` for port 465, `starttls` for port 587, and `none` for port 25 or any other port. SMTP authentication is enabled only when both `SMTP_USERNAME` and `SMTP_PASSWORD` are non-empty. Reset links expire after 30 minutes and can be used only once.
## Browser libraries
`static/libs/rustpad-player` is project-owned and stays in the repository. Mermaid and Highlight.js are generated locally and ignored by Git. Refresh or restore them with:
```bash
python3 scripts/update_browser_libs.py
```
The standard-library-only updater checks the current stable npm releases, verifies tarball integrity, and stores each license beside the generated files. `./dev.sh` runs it before local Cargo development. Docker performs the same download in the `browser-libs` stage; `dev.sh` sets `BROWSER_LIBS_REFRESH` so Docker does not reuse a stale dependency layer.
The editor serves all browser libraries through `/assets` and never loads Mermaid or Highlight.js directly from a public CDN.
## Diagnostics and logging
Server logs use `tracing`. Configure verbosity with `RUST_LOG`, for example:
File diff suppressed because it is too large Load Diff
Regular → Executable
+30 -5
View File
@@ -16,16 +16,41 @@ export RUST_LOG="${RUST_LOG:-rustpad=debug,tower_http=info}"
# Generate a new asset version on each run to prevent stale HTML and JavaScript.
export ASSET_VERSION="${ASSET_VERSION:-dev-$(date +%s)}"
if command -v cargo >/dev/null 2>&1; then
echo "Cleaning RustPad build artifacts..."
cargo clean --package rustpad
update_browser_libs() {
local python_bin=""
if command -v python3 >/dev/null 2>&1; then
python_bin="python3"
elif command -v python >/dev/null 2>&1; then
python_bin="python"
fi
echo "Starting RustPad..."
exec cargo run --package rustpad
if [[ -z "$python_bin" ]]; then
echo "Python 3 is required to download browser libraries for local Cargo development." >&2
return 1
fi
echo "Checking browser libraries..."
"$python_bin" scripts/update_browser_libs.py
}
if command -v cargo >/dev/null 2>&1; then
if update_browser_libs; then
echo "Cleaning RustPad build artifacts..."
cargo clean --package rustpad
echo "Starting RustPad..."
exec cargo run --package rustpad
fi
if ! command -v docker >/dev/null 2>&1; then
exit 1
fi
echo "Local browser libraries could not be prepared; falling back to Docker." >&2
fi
if command -v docker >/dev/null 2>&1; then
export IMAGE_TAG="${IMAGE_TAG:-dev}"
export BROWSER_LIBS_REFRESH="${BROWSER_LIBS_REFRESH:-dev-$(date +%s)}"
echo "Starting RustPad with Docker..."
exec docker compose up --build --force-recreate --remove-orphans
+2
View File
@@ -3,6 +3,8 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
BROWSER_LIBS_REFRESH: ${BROWSER_LIBS_REFRESH:-manual}
image: rustpad:${IMAGE_TAG:-local}
restart: unless-stopped
env_file:
@@ -0,0 +1 @@
ALTER TABLE user_editor_preferences ADD COLUMN toolbar_collapsed BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1 @@
ALTER TABLE user_editor_preferences ADD COLUMN toolbar_collapsed BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1 @@
ALTER TABLE user_editor_preferences ADD COLUMN toolbar_collapsed INTEGER NOT NULL DEFAULT 0;
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env python3
"""Download and refresh RustPad's third-party browser libraries.
The script uses only the Python standard library. It resolves the current
stable package version from the npm registry, verifies the downloaded tarball,
and atomically replaces the generated directory under static/libs.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import shutil
import sys
import tarfile
import tempfile
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Callable, Iterable
USER_AGENT = "RustPad browser-library updater/1.0"
DEFAULT_TIMEOUT = 45
class UpdateError(RuntimeError):
pass
@dataclass(frozen=True)
class Library:
key: str
package: str
destination: str
entrypoint: str
repository_fragment: str
installer: Callable[[tarfile.TarFile, Path], None]
def registry_url(package: str) -> str:
encoded = urllib.parse.quote(package, safe="")
return f"https://registry.npmjs.org/{encoded}/latest"
def request_bytes(url: str, timeout: int) -> bytes:
request = urllib.request.Request(
url,
headers={
"Accept": "application/json, application/octet-stream;q=0.9, */*;q=0.8",
"User-Agent": USER_AGENT,
},
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read()
except (urllib.error.URLError, TimeoutError, OSError) as error:
raise UpdateError(f"Cannot download {url}: {error}") from error
def package_metadata(package: str, timeout: int) -> dict:
raw = request_bytes(registry_url(package), timeout)
try:
metadata = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise UpdateError(f"Invalid npm metadata for {package}") from error
if not isinstance(metadata, dict):
raise UpdateError(f"Unexpected npm metadata for {package}")
return metadata
def repository_url(metadata: dict) -> str:
repository = metadata.get("repository", "")
if isinstance(repository, dict):
repository = repository.get("url", "")
return str(repository or "")
def verify_tarball(data: bytes, dist: dict) -> None:
integrity = str(dist.get("integrity") or "")
if integrity:
algorithms = {
"sha512": hashlib.sha512,
"sha384": hashlib.sha384,
"sha256": hashlib.sha256,
}
for token in integrity.split():
algorithm, separator, encoded = token.partition("-")
if not separator or algorithm not in algorithms:
continue
expected = base64.b64decode(encoded)
actual = algorithms[algorithm](data).digest()
if actual != expected:
raise UpdateError(f"Tarball integrity verification failed ({algorithm})")
return
shasum = str(dist.get("shasum") or "")
if shasum and hashlib.sha1(data).hexdigest().lower() != shasum.lower():
raise UpdateError("Tarball SHA-1 verification failed")
def safe_relative(member_name: str, prefix: tuple[str, ...]) -> PurePosixPath | None:
path = PurePosixPath(member_name)
parts = path.parts
if len(parts) <= len(prefix) or tuple(parts[: len(prefix)]) != prefix:
return None
relative = PurePosixPath(*parts[len(prefix) :])
if relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts):
raise UpdateError(f"Unsafe path in package archive: {member_name}")
return relative
def write_member(archive: tarfile.TarFile, member: tarfile.TarInfo, destination: Path) -> None:
if not member.isfile():
return
source = archive.extractfile(member)
if source is None:
raise UpdateError(f"Cannot read {member.name} from package archive")
destination.parent.mkdir(parents=True, exist_ok=True)
with source, destination.open("wb") as output:
shutil.copyfileobj(source, output)
def copy_license(archive: tarfile.TarFile, destination: Path) -> None:
names = {
"package/LICENSE",
"package/LICENSE.txt",
"package/LICENSE.md",
"package/LICENCE",
"package/LICENCE.txt",
"package/LICENCE.md",
}
member = next((item for item in archive.getmembers() if item.isfile() and item.name in names), None)
if member is None:
raise UpdateError("The package archive does not contain a license file")
write_member(archive, member, destination / "LICENSE.txt")
def install_mermaid(archive: tarfile.TarFile, destination: Path) -> None:
copied = 0
for member in archive.getmembers():
relative = safe_relative(member.name, ("package", "dist"))
if relative is None or not member.isfile():
continue
suffix = relative.suffix.lower()
is_entrypoint = relative == PurePosixPath("mermaid.esm.min.mjs")
is_minified_chunk = relative.parts[:2] == ("chunks", "mermaid.esm.min")
if not (is_entrypoint or is_minified_chunk):
continue
if suffix not in {".mjs", ".wasm", ".css"}:
continue
write_member(archive, member, destination / Path(*relative.parts))
copied += 1
if copied == 0 or not (destination / "mermaid.esm.min.mjs").is_file():
raise UpdateError("Mermaid browser entrypoint was not found in the npm package")
copy_license(archive, destination)
def install_highlight(archive: tarfile.TarFile, destination: Path) -> None:
candidates = {
"package/highlight.min.js",
"package/build/highlight.min.js",
}
member = next((item for item in archive.getmembers() if item.isfile() and item.name in candidates), None)
if member is None:
member = next(
(
item
for item in archive.getmembers()
if item.isfile() and PurePosixPath(item.name).name == "highlight.min.js"
),
None,
)
if member is None:
raise UpdateError("Highlight.js browser build was not found in the npm package")
write_member(archive, member, destination / "highlight.min.js")
copy_license(archive, destination)
LIBRARIES = (
Library(
key="mermaid",
package="mermaid",
destination="mermaid",
entrypoint="mermaid.esm.min.mjs",
repository_fragment="mermaid-js/mermaid",
installer=install_mermaid,
),
Library(
key="highlight",
package="@highlightjs/cdn-assets",
destination="highlight",
entrypoint="highlight.min.js",
repository_fragment="highlightjs/highlight.js",
installer=install_highlight,
),
)
def installed_version(destination: Path) -> str:
version_file = destination / "VERSION"
try:
return version_file.read_text(encoding="utf-8").strip()
except OSError:
return ""
def is_complete(library: Library, destination: Path) -> bool:
return (destination / library.entrypoint).is_file() and (destination / "LICENSE.txt").is_file()
def source_note(library: Library, metadata: dict, version: str) -> str:
repository = repository_url(metadata)
return (
f"Package: {library.package}\n"
f"Version: {version}\n"
f"Registry: {registry_url(library.package)}\n"
f"Repository: {repository}\n"
"Generated by scripts/update_browser_libs.py; do not edit or commit this directory.\n"
)
def install_library(
library: Library,
metadata: dict,
destination: Path,
timeout: int,
) -> None:
version = str(metadata.get("version") or "").strip()
dist = metadata.get("dist")
if not version or not isinstance(dist, dict) or not dist.get("tarball"):
raise UpdateError(f"npm metadata for {library.package} is missing version or tarball data")
repository = repository_url(metadata).lower()
if library.repository_fragment.lower() not in repository:
raise UpdateError(
f"Unexpected repository for {library.package}: {repository or 'not provided'}"
)
tarball = request_bytes(str(dist["tarball"]), timeout)
verify_tarball(tarball, dist)
destination.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=f".{library.key}-", dir=destination.parent) as temporary:
staging = Path(temporary) / library.destination
staging.mkdir(parents=True)
archive_path = Path(temporary) / "package.tgz"
archive_path.write_bytes(tarball)
try:
with tarfile.open(archive_path, mode="r:gz") as archive:
library.installer(archive, staging)
except (tarfile.TarError, OSError) as error:
raise UpdateError(f"Cannot unpack {library.package}: {error}") from error
(staging / "VERSION").write_text(f"{version}\n", encoding="utf-8")
(staging / "SOURCE.txt").write_text(
source_note(library, metadata, version), encoding="utf-8"
)
if not is_complete(library, staging):
raise UpdateError(f"Generated {library.key} directory is incomplete")
old_destination = destination.with_name(f".{destination.name}.old")
if old_destination.exists():
shutil.rmtree(old_destination)
if destination.exists():
destination.replace(old_destination)
try:
shutil.move(str(staging), str(destination))
except Exception:
if old_destination.exists() and not destination.exists():
old_destination.replace(destination)
raise
finally:
if old_destination.exists():
shutil.rmtree(old_destination)
def selected_libraries(keys: Iterable[str]) -> list[Library]:
requested = set(keys)
if not requested:
return list(LIBRARIES)
return [library for library in LIBRARIES if library.key in requested]
def update_one(
library: Library,
libs_root: Path,
*,
timeout: int,
force: bool,
check: bool,
strict: bool,
) -> bool:
destination = libs_root / library.destination
current = installed_version(destination)
complete = is_complete(library, destination)
try:
metadata = package_metadata(library.package, timeout)
except UpdateError as error:
if complete and not strict:
print(f"warning: {error}; keeping {library.key} {current or 'local copy'}", file=sys.stderr)
return True
raise
latest = str(metadata.get("version") or "").strip()
if not latest:
raise UpdateError(f"npm did not return a version for {library.package}")
if complete and current == latest and not force:
print(f"{library.key}: up to date ({latest})")
return True
state = "missing" if not complete else f"{current or 'unknown'} -> {latest}"
if check:
print(f"{library.key}: update required ({state})")
return False
print(f"{library.key}: downloading {latest} ({state})")
install_library(library, metadata, destination, timeout)
print(f"{library.key}: installed {latest}")
return True
def parse_args() -> argparse.Namespace:
script_root = Path(__file__).resolve().parent.parent
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=script_root, help="RustPad repository root")
parser.add_argument("--library", action="append", choices=[item.key for item in LIBRARIES], default=[])
parser.add_argument("--force", action="store_true", help="download again even when the version is current")
parser.add_argument("--check", action="store_true", help="only report whether an update is required")
parser.add_argument("--strict", action="store_true", help="fail when the registry cannot be reached")
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="network timeout in seconds")
return parser.parse_args()
def main() -> int:
args = parse_args()
root = args.root.resolve()
libs_root = root / "static" / "libs"
if args.timeout < 1:
print("error: --timeout must be at least 1 second", file=sys.stderr)
return 2
success = True
try:
for library in selected_libraries(args.library):
success = update_one(
library,
libs_root,
timeout=args.timeout,
force=args.force,
check=args.check,
strict=args.strict or args.check,
) and success
except UpdateError as error:
print(f"error: {error}", file=sys.stderr)
return 1
return 0 if success else 1
if __name__ == "__main__":
raise SystemExit(main())
+123 -6
View File
@@ -130,7 +130,7 @@ pub(super) fn content_references_file(content: &str, filename: &str, url: &str)
if content.contains(url) {
return true;
}
for marker in ["[file=", "[image=", "[img="] {
for marker in ["[file=", "[image=", "[img=", "[video="] {
let mut remaining = content;
while let Some(index) = remaining.find(marker) {
let after = &remaining[index + marker.len()..];
@@ -513,17 +513,38 @@ async fn require_upload_permission(
.ok_or_else(|| ApiError::forbidden("File uploads are disabled for guests."))
}
#[derive(Debug, Default, Deserialize)]
pub struct FileDownloadQuery {
#[serde(default)]
download: Option<String>,
}
impl FileDownloadQuery {
fn force_download(&self) -> bool {
self.download.as_deref().is_some_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "download"
)
})
}
}
pub async fn download_file(
State(state): State<SharedState>,
headers: HeaderMap,
Path((token, filename)): Path<(String, String)>,
Query(query): Query<FileDownloadQuery>,
) -> Result<Response, ApiError> {
serve_token_file(&state, &token, &filename).await
serve_token_file(&state, &headers, &token, &filename, query.force_download()).await
}
async fn serve_token_file(
state: &SharedState,
headers: &HeaderMap,
token: &str,
filename: &str,
force_download: bool,
) -> Result<Response, ApiError> {
let safe = sanitize_filename(filename);
if safe != filename {
@@ -543,15 +564,44 @@ async fn serve_token_file(
.get_local_with_legacy(&key, &legacy_key)
.await
.map_err(|_| ApiError::not_found_file())?;
let total_len = bytes.len();
let guessed_mime = mime_guess::from_path(&safe).first_or_octet_stream();
let inline_image = is_safe_inline_image_mime(guessed_mime.essence_str());
let served_mime = if inline_image {
let safe_inline = is_safe_inline_image_mime(guessed_mime.essence_str())
|| is_safe_inline_video_mime(guessed_mime.essence_str());
let served_mime = if safe_inline {
guessed_mime.as_ref()
} else {
"application/octet-stream"
};
let disposition = if inline_image { "inline" } else { "attachment" };
let mut response = bytes.into_response();
let disposition = if safe_inline && !force_download {
"inline"
} else {
"attachment"
};
let requested_range = headers
.get(header::RANGE)
.and_then(|value| value.to_str().ok());
let range = match requested_range {
Some(value) => match parse_byte_range(value, total_len) {
Ok(range) => range,
Err(()) => return Ok(range_not_satisfiable(total_len)),
},
None => None,
};
let (body, status, content_range) = if let Some((start, end)) = range {
(
bytes.slice(start..end),
StatusCode::PARTIAL_CONTENT,
Some(format!("bytes {start}-{}/{}", end - 1, total_len)),
)
} else {
(bytes, StatusCode::OK, None)
};
let body_len = body.len();
let mut response = body.into_response();
*response.status_mut() = status;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_str(served_mime)
@@ -562,6 +612,20 @@ async fn serve_token_file(
HeaderValue::from_str(&format!("{disposition}; filename=\"{safe}\""))
.expect("sanitized attachment filename"),
);
response.headers_mut().insert(
header::ACCEPT_RANGES,
HeaderValue::from_static("bytes"),
);
response.headers_mut().insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(&body_len.to_string()).expect("valid content length"),
);
if let Some(content_range) = content_range {
response.headers_mut().insert(
header::CONTENT_RANGE,
HeaderValue::from_str(&content_range).expect("valid content range"),
);
}
response.headers_mut().insert(
HeaderName::from_static("content-security-policy"),
HeaderValue::from_static("default-src 'none'; sandbox"),
@@ -584,6 +648,52 @@ async fn serve_token_file(
Ok(response)
}
fn range_not_satisfiable(total_len: usize) -> Response {
let mut response = StatusCode::RANGE_NOT_SATISFIABLE.into_response();
response.headers_mut().insert(
header::CONTENT_RANGE,
HeaderValue::from_str(&format!("bytes */{total_len}")).expect("valid content range"),
);
response.headers_mut().insert(
header::ACCEPT_RANGES,
HeaderValue::from_static("bytes"),
);
response
}
fn parse_byte_range(value: &str, total_len: usize) -> Result<Option<(usize, usize)>, ()> {
if total_len == 0 {
return Err(());
}
let range = value.trim().strip_prefix("bytes=").ok_or(())?;
if range.contains(',') {
return Err(());
}
let (start, end) = range.split_once('-').ok_or(())?;
if start.is_empty() {
let suffix_len = end.parse::<usize>().map_err(|_| ())?;
if suffix_len == 0 {
return Err(());
}
let start = total_len.saturating_sub(suffix_len);
return Ok(Some((start, total_len)));
}
let start = start.parse::<usize>().map_err(|_| ())?;
if start >= total_len {
return Err(());
}
let end_inclusive = if end.is_empty() {
total_len - 1
} else {
end.parse::<usize>().map_err(|_| ())?.min(total_len - 1)
};
if end_inclusive < start {
return Err(());
}
Ok(Some((start, end_inclusive + 1)))
}
fn is_safe_inline_image_mime(value: &str) -> bool {
matches!(
value,
@@ -597,6 +707,13 @@ fn is_safe_inline_image_mime(value: &str) -> bool {
)
}
fn is_safe_inline_video_mime(value: &str) -> bool {
matches!(
value,
"video/mp4" | "video/webm" | "video/ogg" | "video/quicktime" | "video/x-m4v"
)
}
fn sanitize_filename(value: &str) -> String {
let name = std::path::Path::new(value)
.file_name()
+8
View File
@@ -432,6 +432,7 @@ pub struct NoteInfo {
editor_line_numbers: bool,
preview_line_numbers: bool,
line_links: bool,
toolbar_collapsed: bool,
font_family: String,
font_size: i64,
personal_editor_settings: bool,
@@ -461,6 +462,8 @@ pub struct EditorSettingsRequest {
#[serde(default)]
line_links: Option<bool>,
#[serde(default)]
toolbar_collapsed: Option<bool>,
#[serde(default)]
font_family: Option<String>,
#[serde(default)]
font_size: Option<i64>,
@@ -502,6 +505,7 @@ async fn save_editor_settings(
|| payload.editor_line_numbers.is_some()
|| payload.preview_line_numbers.is_some()
|| payload.line_links.is_some()
|| payload.toolbar_collapsed.is_some()
|| payload.font_family.is_some()
|| payload.font_size.is_some();
let wants_global_update = payload.authorship_mode.is_some() || payload.colors_enabled.is_some();
@@ -553,6 +557,9 @@ async fn save_editor_settings(
if let Some(value) = payload.line_links {
preferences.line_links = value;
}
if let Some(value) = payload.toolbar_collapsed {
preferences.toolbar_collapsed = value;
}
if let Some(value) = payload.font_family {
preferences.font_family = match value.as_str() {
"mono" | "system" | "serif" | "arial" | "georgia" => value,
@@ -1084,6 +1091,7 @@ pub async fn note_info(
editor_line_numbers: editor_preferences.editor_line_numbers,
preview_line_numbers: editor_preferences.preview_line_numbers,
line_links: editor_preferences.line_links,
toolbar_collapsed: editor_preferences.toolbar_collapsed,
font_family: editor_preferences.font_family,
font_size: editor_preferences.font_size,
personal_editor_settings,
+2
View File
@@ -54,6 +54,7 @@ pub struct PadInfo {
editor_line_numbers: bool,
preview_line_numbers: bool,
line_links: bool,
toolbar_collapsed: bool,
font_family: String,
font_size: i64,
personal_editor_settings: bool,
@@ -172,6 +173,7 @@ pub async fn pad_info(
editor_line_numbers: editor_preferences.editor_line_numbers,
preview_line_numbers: editor_preferences.preview_line_numbers,
line_links: editor_preferences.line_links,
toolbar_collapsed: editor_preferences.toolbar_collapsed,
font_family: editor_preferences.font_family,
font_size: editor_preferences.font_size,
personal_editor_settings,
+21 -7
View File
@@ -38,6 +38,7 @@ const MODULES: &[&str] = &[
"toast",
"theme",
"url-state",
"vendor-libs",
"security",
];
@@ -51,10 +52,20 @@ pub fn render_html(
entrypoint: &str,
) -> Response {
let urls = AssetUrls::new(asset_version);
let frontend_config = frontend_config(frontend_log_level, upload_max_size_bytes, external_auth);
let frontend_config = frontend_config(
frontend_log_level,
upload_max_size_bytes,
external_auth,
asset_version,
);
let app_stylesheets = format!(
"{}{}",
urls.stylesheet("styles"),
urls.stylesheet_path("libs/rustpad-player/player.css"),
);
let html = template
.replace("__APP_THEME_BOOTSTRAP__", theme_bootstrap())
.replace("__APP_STYLESHEET__", &urls.stylesheet("styles"))
.replace("__APP_STYLESHEET__", &app_stylesheets)
.replace("__APP_IMPORT_MAP__", &urls.import_map())
.replace("__APP_ENTRYPOINT__", &urls.entrypoint(entrypoint))
.replace(
@@ -92,12 +103,14 @@ fn frontend_config(
frontend_log_level: &str,
upload_max_size_bytes: usize,
external_auth: bool,
asset_version: &str,
) -> String {
format!(
r#"<script>window.__RUSTPAD_CONFIG__=Object.freeze({{frontendLogLevel:"{}",uploadMaxSizeBytes:{},externalAuth:{}}});</script>"#,
r#"<script>window.__RUSTPAD_CONFIG__=Object.freeze({{frontendLogLevel:"{}",uploadMaxSizeBytes:{},externalAuth:{},assetVersion:"{}"}});</script>"#,
escape_js_string(frontend_log_level),
upload_max_size_bytes,
external_auth,
escape_js_string(asset_version),
)
}
@@ -115,10 +128,11 @@ impl<'a> AssetUrls<'a> {
}
fn stylesheet(&self, name: &str) -> String {
format!(
r#"<link rel="stylesheet" href="{}">"#,
self.url(&format!("css/{name}.css"))
)
self.stylesheet_path(&format!("css/{name}.css"))
}
fn stylesheet_path(&self, path: &str) -> String {
format!(r#"<link rel="stylesheet" href="{}">"#, self.url(path))
}
fn entrypoint(&self, name: &str) -> String {
+6 -2
View File
@@ -15,6 +15,7 @@ pub struct EditorPreferences {
pub editor_line_numbers: bool,
pub preview_line_numbers: bool,
pub line_links: bool,
pub toolbar_collapsed: bool,
pub font_family: String,
pub font_size: i64,
}
@@ -26,6 +27,7 @@ impl Default for EditorPreferences {
editor_line_numbers: true,
preview_line_numbers: false,
line_links: false,
toolbar_collapsed: false,
font_family: "mono".into(),
font_size: 14,
}
@@ -92,8 +94,9 @@ pub async fn load_editor_preferences(
editor_line_numbers: row.try_get::<i64, _>(1)? != 0,
preview_line_numbers: row.try_get::<i64, _>(2)? != 0,
line_links: row.try_get::<i64, _>(3)? != 0,
font_family: crate::row_decode::text(&row, 4)?,
font_size: row.try_get(5)?,
toolbar_collapsed: row.try_get::<i64, _>(4)? != 0,
font_family: crate::row_decode::text(&row, 5)?,
font_size: row.try_get(6)?,
}))
}
@@ -117,6 +120,7 @@ pub async fn save_editor_configuration(
.bind(preferences.editor_line_numbers)
.bind(preferences.preview_line_numbers)
.bind(preferences.line_links)
.bind(preferences.toolbar_collapsed)
.bind(&preferences.font_family)
.bind(preferences.font_size)
.execute(&mut *tx)
+4 -4
View File
@@ -59,16 +59,16 @@ pub fn get(query: Query) -> &'static str {
r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
}
Query::EDITOR_PREFERENCES_SELECT_PAD => {
r#"SELECT CAST(CASE WHEN compact_view THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN editor_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN preview_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN line_links THEN 1 ELSE 0 END AS SIGNED), CAST(font_family AS CHAR CHARACTER SET utf8mb4), font_size FROM user_editor_preferences WHERE user_id = ? AND pad_id = ?"#
r#"SELECT CAST(CASE WHEN compact_view THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN editor_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN preview_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN line_links THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN toolbar_collapsed THEN 1 ELSE 0 END AS SIGNED), CAST(font_family AS CHAR CHARACTER SET utf8mb4), font_size FROM user_editor_preferences WHERE user_id = ? AND pad_id = ?"#
}
Query::EDITOR_PREFERENCES_SELECT_NOTE => {
r#"SELECT CAST(CASE WHEN compact_view THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN editor_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN preview_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN line_links THEN 1 ELSE 0 END AS SIGNED), CAST(font_family AS CHAR CHARACTER SET utf8mb4), font_size FROM user_editor_preferences WHERE user_id = ? AND note_id = ?"#
r#"SELECT CAST(CASE WHEN compact_view THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN editor_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN preview_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN line_links THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN toolbar_collapsed THEN 1 ELSE 0 END AS SIGNED), CAST(font_family AS CHAR CHARACTER SET utf8mb4), font_size FROM user_editor_preferences WHERE user_id = ? AND note_id = ?"#
}
Query::EDITOR_PREFERENCES_UPSERT_PAD => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, toolbar_collapsed, font_family, font_size, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), toolbar_collapsed = VALUES(toolbar_collapsed), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, toolbar_collapsed, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), toolbar_collapsed = VALUES(toolbar_collapsed), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
r#"DELETE FROM user_editor_preferences WHERE user_id = ?"#
+4 -4
View File
@@ -59,16 +59,16 @@ pub fn get(query: Query) -> &'static str {
r#"DELETE FROM resource_editor_settings WHERE resource_kind = $1 AND resource_slug = $2"#
}
Query::EDITOR_PREFERENCES_SELECT_PAD => {
r#"SELECT (CASE WHEN compact_view THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN editor_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN preview_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN line_links THEN 1 ELSE 0 END)::BIGINT, font_family, font_size FROM user_editor_preferences WHERE user_id = $1 AND pad_id = $2"#
r#"SELECT (CASE WHEN compact_view THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN editor_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN preview_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN line_links THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN toolbar_collapsed THEN 1 ELSE 0 END)::BIGINT, font_family, font_size FROM user_editor_preferences WHERE user_id = $1 AND pad_id = $2"#
}
Query::EDITOR_PREFERENCES_SELECT_NOTE => {
r#"SELECT (CASE WHEN compact_view THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN editor_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN preview_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN line_links THEN 1 ELSE 0 END)::BIGINT, font_family, font_size FROM user_editor_preferences WHERE user_id = $1 AND note_id = $2"#
r#"SELECT (CASE WHEN compact_view THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN editor_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN preview_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN line_links THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN toolbar_collapsed THEN 1 ELSE 0 END)::BIGINT, font_family, font_size FROM user_editor_preferences WHERE user_id = $1 AND note_id = $2"#
}
Query::EDITOR_PREFERENCES_UPSERT_PAD => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, pad_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, toolbar_collapsed, font_family, font_size, updated_at) VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, $9, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, pad_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, toolbar_collapsed = EXCLUDED.toolbar_collapsed, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
}
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES ($1, NULL, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, toolbar_collapsed, font_family, font_size, updated_at) VALUES ($1, NULL, $2, $3, $4, $5, $6, $7, $8, $9, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, toolbar_collapsed = EXCLUDED.toolbar_collapsed, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"#
}
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
r#"DELETE FROM user_editor_preferences WHERE user_id = $1"#
+4 -4
View File
@@ -59,16 +59,16 @@ pub fn get(query: Query) -> &'static str {
r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
}
Query::EDITOR_PREFERENCES_SELECT_PAD => {
r#"SELECT CASE WHEN compact_view THEN 1 ELSE 0 END, CASE WHEN editor_line_numbers THEN 1 ELSE 0 END, CASE WHEN preview_line_numbers THEN 1 ELSE 0 END, CASE WHEN line_links THEN 1 ELSE 0 END, font_family, font_size FROM user_editor_preferences WHERE user_id = ? AND pad_id = ?"#
r#"SELECT CASE WHEN compact_view THEN 1 ELSE 0 END, CASE WHEN editor_line_numbers THEN 1 ELSE 0 END, CASE WHEN preview_line_numbers THEN 1 ELSE 0 END, CASE WHEN line_links THEN 1 ELSE 0 END, CASE WHEN toolbar_collapsed THEN 1 ELSE 0 END, font_family, font_size FROM user_editor_preferences WHERE user_id = ? AND pad_id = ?"#
}
Query::EDITOR_PREFERENCES_SELECT_NOTE => {
r#"SELECT CASE WHEN compact_view THEN 1 ELSE 0 END, CASE WHEN editor_line_numbers THEN 1 ELSE 0 END, CASE WHEN preview_line_numbers THEN 1 ELSE 0 END, CASE WHEN line_links THEN 1 ELSE 0 END, font_family, font_size FROM user_editor_preferences WHERE user_id = ? AND note_id = ?"#
r#"SELECT CASE WHEN compact_view THEN 1 ELSE 0 END, CASE WHEN editor_line_numbers THEN 1 ELSE 0 END, CASE WHEN preview_line_numbers THEN 1 ELSE 0 END, CASE WHEN line_links THEN 1 ELSE 0 END, CASE WHEN toolbar_collapsed THEN 1 ELSE 0 END, font_family, font_size FROM user_editor_preferences WHERE user_id = ? AND note_id = ?"#
}
Query::EDITOR_PREFERENCES_UPSERT_PAD => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, pad_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, toolbar_collapsed, font_family, font_size, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, pad_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, toolbar_collapsed = excluded.toolbar_collapsed, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_UPSERT_NOTE => {
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, toolbar_collapsed, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, toolbar_collapsed = excluded.toolbar_collapsed, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"#
}
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
r#"DELETE FROM user_editor_preferences WHERE user_id = ?"#
+31 -1
View File
@@ -7,7 +7,10 @@
* See LICENSE file in repository root for details.
*/
use super::{content_references_file, content_references_stored_file, is_safe_inline_image_mime};
use super::{
content_references_file, content_references_stored_file, is_safe_inline_image_mime,
is_safe_inline_video_mime, parse_byte_range,
};
#[test]
fn only_raster_images_are_inline() {
@@ -48,3 +51,30 @@ fn attachment_references_survive_origin_changes() {
Some("https://new-files.example.com"),
));
}
#[test]
fn common_video_formats_are_inline() {
assert!(is_safe_inline_video_mime("video/mp4"));
assert!(is_safe_inline_video_mime("video/webm"));
assert!(!is_safe_inline_video_mime("text/html"));
assert!(!is_safe_inline_video_mime("application/javascript"));
}
#[test]
fn video_alias_is_still_attached() {
assert!(content_references_file(
"[video=clip.mp4,Product demo]",
"clip.mp4",
"/f/token/clip.mp4",
));
}
#[test]
fn byte_ranges_support_video_seeking() {
assert_eq!(parse_byte_range("bytes=0-99", 1_000), Ok(Some((0, 100))));
assert_eq!(parse_byte_range("bytes=500-", 1_000), Ok(Some((500, 1_000))));
assert_eq!(parse_byte_range("bytes=-100", 1_000), Ok(Some((900, 1_000))));
assert_eq!(parse_byte_range("bytes=900-2000", 1_000), Ok(Some((900, 1_000))));
assert_eq!(parse_byte_range("bytes=1000-", 1_000), Err(()));
assert_eq!(parse_byte_range("bytes=0-1,4-5", 1_000), Err(()));
}
+2 -2
View File
@@ -14,8 +14,8 @@ fn boolean_projections_are_normalized_for_sqlx_any() {
// MySQL BOOLEAN is TINYINT(1), which sqlx::Any 0.8 cannot map directly.
for (query, expected_casts) in [
(Query::RESOURCE_EDITOR_SETTINGS_SELECT, 1),
(Query::EDITOR_PREFERENCES_SELECT_PAD, 4),
(Query::EDITOR_PREFERENCES_SELECT_NOTE, 4),
(Query::EDITOR_PREFERENCES_SELECT_PAD, 5),
(Query::EDITOR_PREFERENCES_SELECT_NOTE, 5),
(Query::AUTH_USER_BY_EXTERNAL_ID, 1),
(Query::AUTH_USER_BY_SESSION, 1),
(Query::AUTH_USER_BY_NICKNAME, 1),
+313 -32
View File
@@ -831,6 +831,93 @@ textarea:focus {
color: var(--text);
}
/* Focus mode removes the toolbar row and leaves only compact floating controls. */
.pad-page .toolbar-collapse-toggle {
display: inline-grid;
flex: 0 0 auto;
width: 36px;
min-width: 36px;
height: 36px;
min-height: 36px;
padding: 0;
place-items: center;
overflow: hidden;
border-color: var(--border);
background: var(--surface-inset);
color: var(--muted);
}
.pad-page .toolbar-collapse-toggle:hover {
color: var(--text);
}
.toolbar-collapse-toggle__icon {
display: grid;
width: 100%;
height: 100%;
place-items: center;
font-size: 1.35rem;
font-weight: 800;
line-height: 1;
pointer-events: none;
transform: scaleX(1.3);
transform-origin: center;
}
.pad-page.toolbar-collapsed .editor-panel {
grid-template-rows: minmax(0, 1fr) auto;
}
.pad-page.toolbar-collapsed .editor-toolbar {
position: absolute;
top: 8px;
right: 8px;
z-index: 16;
display: flex;
width: auto;
max-width: calc(100% - 16px);
min-height: 0;
padding: 3px;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 4px;
border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--surface-toolbar) 88%, transparent);
box-shadow: 0 8px 24px var(--shadow-28);
backdrop-filter: blur(10px);
}
.pad-page.toolbar-collapsed .editor-toolbar > :not(.toolbar-collapse-toggle):not(.view-switch) {
display: none !important;
}
.pad-page.toolbar-collapsed .editor-toolbar > .toolbar-collapse-toggle,
.pad-page.toolbar-collapsed .editor-toolbar > .view-switch {
position: static;
display: inline-flex !important;
width: auto;
margin: 0;
}
.pad-page.toolbar-collapsed .editor-toolbar > .toolbar-collapse-toggle {
order: 1;
width: 36px;
min-width: 36px;
height: 36px;
min-height: 36px;
padding: 0;
}
.pad-page.toolbar-collapsed .editor-toolbar > .view-switch {
order: 2;
}
.pad-page.toolbar-collapsed .editor-toolbar .view-switch button {
align-items: center;
justify-content: center;
}
.workspace {
display: grid;
min-height: 0;
@@ -2404,6 +2491,69 @@ dialog::backdrop {
}
}
/* Choice shown when a video file is uploaded. */
.video-insert-dialog {
width: min(560px, calc(100vw - 24px));
max-width: 560px;
}
.video-insert-dialog__panel {
display: grid;
gap: 18px;
padding: 24px;
border: 1px solid var(--border-strong);
border-radius: 14px;
background: var(--surface-panel);
color: var(--text);
box-shadow: 0 24px 70px var(--shadow-42);
}
.video-insert-dialog__panel .dialog-heading-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.video-insert-dialog__panel h2,
.video-insert-dialog__panel .eyebrow,
.video-insert-dialog__panel .dialog-copy {
margin: 0;
}
.video-choice-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.video-choice-actions > button {
display: grid;
justify-items: start;
gap: 5px;
min-width: 0;
min-height: 86px;
padding: 14px;
text-align: left;
}
.video-choice-actions span {
color: var(--muted);
font-size: .76rem;
font-weight: 500;
line-height: 1.4;
}
@media (max-width: 600px) {
.video-choice-actions {
grid-template-columns: minmax(0, 1fr);
}
.video-insert-dialog__panel {
padding: 20px 14px 14px;
}
}
.notes-toolbar {
display: flex;
align-items: center;
@@ -3299,6 +3449,39 @@ dialog::backdrop {
grid-column: 1 / -1;
}
#profile-dialog,
#profile-dialog .identity-panel,
#profile-dialog .identity-fields,
#profile-dialog .identity-fields > *,
#profile-dialog .profile-actions,
#profile-dialog .profile-theme-field,
#profile-dialog .theme-options,
#profile-dialog .theme-option {
min-width: 0;
box-sizing: border-box;
}
#profile-dialog .identity-panel {
overflow-x: hidden;
}
#profile-dialog .identity-fields input:not([type="radio"]):not([type="checkbox"]),
#profile-dialog .identity-fields select,
#profile-dialog .identity-fields textarea {
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
#profile-dialog .theme-option,
#profile-dialog .theme-option span,
#profile-dialog .theme-option strong,
#profile-dialog .theme-option small,
#profile-dialog .profile-actions button {
overflow-wrap: anywhere;
}
.profile-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -3406,21 +3589,42 @@ dialog::backdrop {
}
#profile-dialog {
width: min(440px, calc(100% - 24px));
width: calc(100vw - 16px);
max-width: none;
max-height: calc(100dvh - 16px);
margin: auto;
}
#profile-dialog .identity-panel {
width: 100%;
max-height: calc(100dvh - 16px);
padding: 22px 14px 14px;
gap: 14px;
scrollbar-gutter: auto;
}
#profile-dialog .identity-panel__header {
padding-right: 36px;
}
#profile-dialog .identity-panel,
#profile-dialog .identity-fields,
.profile-actions {
grid-template-columns: 1fr;
#profile-dialog .profile-actions,
#profile-dialog .theme-options {
grid-template-columns: minmax(0, 1fr);
}
#profile-dialog .identity-fields>*,
#profile-dialog .identity-fields > *,
#profile-dialog .profile-actions,
#profile-dialog .form-message {
grid-column: 1;
}
#profile-dialog .profile-actions > button {
min-width: 0;
white-space: normal;
}
.identity-links {
align-items: flex-start;
flex-direction: column;
@@ -3567,6 +3771,10 @@ dialog::backdrop {
padding: 7px 10px;
}
.resource-action-label--short {
display: none;
}
.resource-password-menu {
position: relative;
flex: 0 0 auto;
@@ -3636,16 +3844,6 @@ dialog::backdrop {
background: var(--danger-subtle-bg);
}
@media (max-width:640px) {
.resource-row {
align-items: flex-start;
flex-direction: column
}
.resource-actions {
width: 100%
}
}
.resource-main {
display: flex;
@@ -3695,18 +3893,6 @@ dialog::backdrop {
margin: 0;
}
@media (max-width: 640px) {
.resource-main {
align-items: flex-start;
flex-direction: column;
}
.resource-actions {
width: 100%;
margin-left: 0;
}
}
.resource-row {
align-items: stretch;
flex-direction: column;
@@ -4457,6 +4643,81 @@ dialog::backdrop {
background: color-mix(in srgb, var(--accent) 4%, transparent);
}
@media (max-width: 640px) {
#resources-dialog {
width: calc(100% - 12px);
}
#resources-dialog .resources-panel {
padding: 14px;
}
#resources-dialog .resources-panel__header {
padding-right: 34px;
}
.resource-row {
padding: 10px;
}
.resource-main {
align-items: stretch;
justify-content: flex-start;
flex-direction: column;
gap: 10px;
}
.resource-copy {
width: 100%;
flex: 0 1 auto;
}
.resource-title-line {
display: block;
}
.resource-title-line a {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resource-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
width: 100%;
margin-left: 0;
gap: 4px;
}
.resource-actions > button,
.resource-actions > .resource-password-menu,
.resource-password-menu > summary {
width: 100%;
min-width: 0;
}
.resource-actions .compact-button {
min-width: 0;
padding: 7px 4px;
gap: 3px;
font-size: .72rem;
}
.resource-action-label--full {
display: none;
}
.resource-action-label--short {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@media (max-width: 640px) {
.share-dialog,
@@ -5211,7 +5472,7 @@ dialog::backdrop {
.pad-page .editor-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 8px;
min-height: 0;
padding: 8px;
@@ -5235,6 +5496,8 @@ dialog::backdrop {
.pad-page #mode-toggle {
display: inline-flex;
grid-column: 2;
grid-row: 1;
width: auto;
min-width: 0;
align-self: center;
@@ -5242,6 +5505,12 @@ dialog::backdrop {
white-space: nowrap;
}
.pad-page .toolbar-collapse-toggle {
grid-column: 3;
grid-row: 1;
align-self: center;
}
.pad-page .toolbar-group:has(>.emoji-picker[open]),
.pad-page .toolbar-group:has(>.markdown-more[open]) {
overflow: visible;
@@ -5251,6 +5520,8 @@ dialog::backdrop {
position: sticky;
right: 0;
z-index: 2;
grid-column: 4;
grid-row: 1;
width: max-content;
max-width: 100%;
align-self: center;
@@ -5298,7 +5569,7 @@ dialog::backdrop {
}
.pad-page .editor-toolbar {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
}
@@ -6741,7 +7012,7 @@ dialog::backdrop {
@media (max-width: 760px) and (orientation: portrait) {
.pad-page .editor-toolbar {
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-columns: minmax(0, 1fr) auto auto auto;
width: 100%;
max-width: 100vw;
}
@@ -6948,11 +7219,16 @@ dialog::backdrop {
grid-row: 1;
}
.pad-page .view-switch {
.pad-page .toolbar-collapse-toggle {
grid-column: 2;
grid-row: 1;
}
.pad-page .view-switch {
grid-column: 3;
grid-row: 1;
}
.pad-page .mobile-upload-button {
display: inline-flex;
grid-column: 1 / -1;
@@ -6989,8 +7265,13 @@ dialog::backdrop {
grid-row: 1;
}
.pad-page .view-switch {
.pad-page .toolbar-collapse-toggle {
grid-column: 3;
grid-row: 1;
}
}
.pad-page .view-switch {
grid-column: 4;
grid-row: 1;
}
}
+5 -2
View File
@@ -117,7 +117,7 @@
</details>
</div>
<button id="mobile-upload-button" class="mobile-upload-button" type="button"
title="Upload an image or file"><span aria-hidden="true"></span> Upload file</button>
title="Upload a file"><span aria-hidden="true"></span> Upload</button>
<div class="editor-controls"><label>Font<select id="font-family">
<option value="mono">Mono</option>
<option value="system">System</option>
@@ -131,7 +131,7 @@
<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="toolbar-action" title="Upload a file">Upload</button><input id="file-input" type="file" hidden><label
class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Editor
lines</label><label class="line-toggle"><input id="preview-line-numbers-toggle" type="checkbox">
Preview lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked>
@@ -141,6 +141,9 @@
aria-pressed="true" aria-label="Markdown" title="Markdown"><span
class="control-label-full">Markdown</span><span class="control-label-short"
aria-hidden="true">M</span></button>
<button id="toolbar-collapse-toggle" class="toolbar-collapse-toggle" type="button"
aria-pressed="false" aria-label="Hide editor toolbar" title="Hide editor toolbar"><span
class="toolbar-collapse-toggle__icon" aria-hidden="true"></span></button>
<div class="view-switch" aria-label="Editor view"><button data-view="edit" class="active"
aria-label="Edit" title="Edit"><span class="control-label-full">Edit</span><span
class="control-label-short" aria-hidden="true">E</span></button><button data-view="split"
+18 -2
View File
@@ -119,6 +119,20 @@ let currentSession = null;
function authHeaders() { return {}; }
function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; }
function resourceActionLabel(full, short = full) {
return `<span class="resource-action-label resource-action-label--full">${escapeHtml(full)}</span><span class="resource-action-label resource-action-label--short" aria-hidden="true">${escapeHtml(short)}</span>`;
}
function setResourcePrivacyLabel(button, isPrivate) {
if (!button) return;
const full = isPrivate ? "Make public" : "Make private";
const short = isPrivate ? "Public" : "Private";
button.setAttribute("aria-label", full);
button.title = full;
const fullLabel = button.querySelector(".resource-action-label--full");
const shortLabel = button.querySelector(".resource-action-label--short");
if (fullLabel) fullLabel.textContent = full;
if (shortLabel) shortLabel.textContent = short;
}
function shareExpiry(hours, forever) { if (forever) return null; const value = Number(hours); if (!Number.isFinite(value) || value <= 0 || value > 87600) throw new Error("Enter a validity between 1 and 87600 hours."); return new Date(Date.now() + value * 3600000).toISOString(); }
function formatShareExpiry(value) { if (!value) return "Never expires"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Expires ${date.toLocaleString()}`; }
function formatShareCreated(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Created ${date.toLocaleString()}`; }
@@ -157,7 +171,9 @@ async function loadResources() {
const passwordActions = item.protected
? `<button class="resource-password-menu__item" type="button" data-password>Change password</button><button class="resource-password-menu__item resource-password-menu__item--danger" type="button" data-remove-password>Remove password</button>`
: `<button class="resource-password-menu__item" type="button" data-password>Set password</button>`;
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button class="action-button action-button--primary compact-button" type="button" data-share>Share</button><details class="resource-password-menu"><summary class="action-button action-button--secondary compact-button">Password…<span class="resource-password-menu__chevron" aria-hidden="true">▾</span></summary><div class="resource-password-menu__panel">${passwordActions}</div></details><button class="action-button action-button--danger compact-button" type="button" data-delete>Delete</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
const privacyAction = item.private ? "Make public" : "Make private";
const privacyShort = item.private ? "Public" : "Private";
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}" title="${escapeHtml(item.title)}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy aria-label="${privacyAction}" title="${privacyAction}">${resourceActionLabel(privacyAction, privacyShort)}</button><button class="action-button action-button--primary compact-button" type="button" data-share aria-label="Share" title="Share">${resourceActionLabel("Share")}</button><details class="resource-password-menu"><summary class="action-button action-button--secondary compact-button" aria-label="Password settings" title="Password settings">${resourceActionLabel("Password…", "Pass…")}<span class="resource-password-menu__chevron" aria-hidden="true">▾</span></summary><div class="resource-password-menu__panel">${passwordActions}</div></details><button class="action-button action-button--danger compact-button" type="button" data-delete aria-label="Delete" title="Delete">${resourceActionLabel("Delete")}</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
const inline = row.querySelector("[data-inline]");
const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; };
@@ -175,7 +191,7 @@ async function loadResources() {
try {
await api("/api/auth/resources/privacy", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, private: nextPrivate }) });
item.private = nextPrivate;
button.textContent = nextPrivate ? "Make public" : "Make private";
setResourcePrivacyLabel(button, nextPrivate);
const meta = row.querySelector(".resource-copy small");
meta.textContent = `${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}`;
} catch (e) {
+80 -3
View File
@@ -43,6 +43,51 @@ function safeUrl(value) {
}
}
function safeAttachmentUrl(file, { download = false } = {}) {
let raw = String(file?.url || "").trim();
const route = attachmentRoute(raw);
if (route && markdownFileRoutes.has(route)) raw = markdownFileRoutes.get(route);
try {
const url = new URL(raw, location.origin);
if (url.protocol !== "http:" && url.protocol !== "https:") return "#";
if (download && route) url.searchParams.set("download", "1");
return escapeHtml(url.href);
} catch {
return "#";
}
}
function safeAttachmentPlaybackUrl(file) {
return safeAttachmentUrl(file);
}
function safeAttachmentDownloadUrl(file) {
return safeAttachmentUrl(file, { download: true });
}
function youtubeVideo(value) {
const raw = String(value || "").trim();
if (!raw || raw.startsWith("//")) return null;
try {
const url = new URL(raw, location.origin);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
const host = url.hostname.toLowerCase();
let id = "";
if (host === "youtu.be") {
id = url.pathname.split("/").filter(Boolean)[0] || "";
} else if (host === "youtube.com" || host.endsWith(".youtube.com") || host === "youtube-nocookie.com" || host.endsWith(".youtube-nocookie.com")) {
if (url.pathname === "/watch") id = url.searchParams.get("v") || "";
else {
const match = url.pathname.match(/^\/(?:shorts|embed|live)\/([A-Za-z0-9_-]+)/);
id = match?.[1] || "";
}
}
return /^[A-Za-z0-9_-]{6,20}$/.test(id) ? { id, url: url.href } : null;
} catch {
return null;
}
}
export function setMarkdownFiles(files) {
const normalized = (Array.isArray(files) ? files : [])
.filter(file => file && file.filename && file.url)
@@ -60,7 +105,7 @@ export function setMarkdownFiles(files) {
export function unresolvedMarkdownFileAliases(value) {
const missing = new Set();
const source = String(value || "").replace(/`[^`]*`/g, "");
for (const match of source.matchAll(/\[(?:file|image|img)=([^,\]\s]+)(?:,[^\]]*)?\]/gi)) {
for (const match of source.matchAll(/\[(?:file|image|img|video)=([^,\]\s]+)(?:,[^\]]*)?\]/gi)) {
if (!markdownFiles.has(match[1])) missing.add(match[1]);
}
return [...missing];
@@ -76,13 +121,18 @@ function inline(value) {
let html = escapeHtml(value);
html = html.replace(/`([^`]+)`/g, (_, code) => stash(`<code>${code}</code>`));
html = html.replace(/\[(file|image|img)=([^,\]\s]+)(?:,([^\]]*))?\]/gi, (match, kind, filename, label) => {
html = html.replace(/\[(file|image|img|video)=([^,\]\s]+)(?:,([^\]]*))?\]/gi, (match, kind, filename, label) => {
const normalizedKind = kind.toLowerCase();
const file = markdownFiles.get(filename);
if (!file) return match;
if (normalizedKind === "file") {
const text = String(label || filename).trim() || filename;
return stash(`<a href="${safeUrl(file.url)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-file-alias="file" data-file-name="${filename}">${text}</a>`);
return stash(`<a href="${safeAttachmentDownloadUrl(file)}" download="${escapeHtml(filename)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-file-alias="file" data-file-name="${escapeHtml(filename)}">${text}</a>`);
}
if (normalizedKind === "video") {
if (!file.mimeType.startsWith("video/")) return match;
const text = String(label || filename).trim() || filename;
return stash(`<a href="${safeAttachmentPlaybackUrl(file)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-file-alias="video" data-file-name="${escapeHtml(filename)}">${text}</a>`);
}
if (!file.mimeType.startsWith("image/")) return match;
@@ -162,6 +212,26 @@ function normalizeLanguage(value) {
const attrs = (line, editable = false, prefix = "", suffix = "", lineOffset = 0) => ` class="preview-source-line${editable ? " preview-editable" : ""}" data-source-line="${line + lineOffset + 1}"${editable ? ` data-source-prefix="${escapeHtml(prefix)}" data-source-suffix="${escapeHtml(suffix)}"` : ""}`;
const isPlainText = line => !/[`*_~^=\[\]<>|:#]/.test(line) && !/^\s*(?:[-+*>]|\d+\.)\s/.test(line);
function renderStandaloneMedia(line, sourceLine) {
const videoAlias = String(line).trim().match(/^\[video=([^,\]\s]+)(?:,([^\]]*))?\]$/i);
if (videoAlias) {
const filename = videoAlias[1];
const file = markdownFiles.get(filename);
if (!file || !file.mimeType.startsWith("video/")) return null;
const label = String(videoAlias[2] || filename).trim() || filename;
const playbackUrl = safeAttachmentPlaybackUrl(file);
const downloadUrl = safeAttachmentDownloadUrl(file);
return `<div class="rustpad-media preview-source-line" data-source-line="${sourceLine}" contenteditable="false"><video class="rustpad-media__player" data-rustpad-player data-player-kind="video" controls playsinline preload="metadata" aria-label="${escapeHtml(label)}"><source src="${playbackUrl}" type="${escapeHtml(file.mimeType)}"></video><p class="rustpad-media__fallback" hidden>Playback is unavailable. <a href="${downloadUrl}" download="${escapeHtml(filename)}">Download ${escapeHtml(label)}</a>.</p></div>`;
}
const trimmed = String(line).trim();
const markdownLink = trimmed.match(/^\[([^\]]+)\]\(([^\s)]+)(?:\s+["'][^"']*["'])?\)$/);
const candidate = markdownLink ? markdownLink[2] : trimmed;
const youtube = youtubeVideo(candidate);
if (!youtube) return null;
const title = markdownLink?.[1]?.trim() || "YouTube video";
return `<div class="rustpad-media preview-source-line" data-source-line="${sourceLine}" contenteditable="false"><div class="rustpad-media__player" data-rustpad-player data-player-kind="youtube" data-video-id="${escapeHtml(youtube.id)}" data-player-title="${escapeHtml(title)}"><p class="rustpad-media__fallback"><a href="${safeUrl(youtube.url)}" target="_blank" rel="noopener noreferrer">Open ${escapeHtml(title)}</a></p></div></div>`;
}
function listLine(line) {
const match = line.match(/^(\s*)([-*+]|(\d+)\.)\s+(?:\[([ xX])\]\s+)?(.+)$/);
@@ -336,6 +406,13 @@ export function renderMarkdown(source, lineOffset = 0) {
}
if (inCode) { code.push(line); continue; }
const standaloneMedia = renderStandaloneMedia(line, index + lineOffset + 1);
if (standaloneMedia) {
closeList();
html += standaloneMedia;
continue;
}
const delimiter = index + 1 < lines.length ? tableDelimiter(lines[index + 1]) : null;
if (line.includes("|") && delimiter) {
closeList();
+101 -10
View File
@@ -28,10 +28,11 @@ import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url
import { toast } from "@rustpad/toast";
import { getTheme } from "@rustpad/theme";
import { isResourceAccessError } from "@rustpad/security";
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
export function startNoteEditor(adapter) {
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
const modeToggle = document.querySelector("#mode-toggle"), toolbarCollapseToggle = document.querySelector("#toolbar-collapse-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), mobileConnectionDetails = document.querySelector("#mobile-connection-details"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message");
const saveState = document.querySelector("#save-state");
editor.readOnly = true;
@@ -40,12 +41,14 @@ export function startNoteEditor(adapter) {
const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle");
const shareToken = new URLSearchParams(location.search).get("share");
const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`;
let toolbarCollapsed = localStorage.getItem(notePreferenceKey("toolbar-collapsed")) === "on";
const collaborationClientId = typeof crypto.randomUUID === "function"
? crypto.randomUUID().replaceAll("-", "")
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
const collaboration = new CollaborationSession(collaborationClientId);
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "", flushRequested = false;
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
let pendingPreviewViewport = null;
const editHistory = {
entries: [],
index: -1,
@@ -230,6 +233,7 @@ export function startNoteEditor(adapter) {
lineToggle.checked = info.editor_line_numbers !== false;
previewLineToggle.checked = info.preview_line_numbers === true;
lineLinksToggle.checked = info.line_links === true;
toolbarCollapsed = info.toolbar_collapsed === true;
if (["mono", "system", "serif", "arial", "georgia"].includes(info.font_family)) fontFamily.value = info.font_family;
if (["14", "16", "18", "20", "22"].includes(String(info.font_size))) fontSize.value = String(info.font_size);
}
@@ -325,8 +329,9 @@ export function startNoteEditor(adapter) {
setStatus(null, "Connecting…");
}
function updateAddressLabel() { document.querySelector(adapter.addressSelector).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: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const mermaid = await loadMermaid(); mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await loadHighlight(); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
async function renderMediaPlayers() { const nodes = preview.querySelectorAll("[data-rustpad-player]"); if (!nodes.length) return; try { const { hydrateMediaPlayers } = await loadMediaPlayer(); hydrateMediaPlayers(preview); } catch { } }
function renderParticipantBadges(owners) {
if (!participantBadges) return;
const people = new Map();
@@ -870,15 +875,21 @@ export function startNoteEditor(adapter) {
return Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : 20;
}
function scrollRatio(element) {
function scrollState(element) {
const range = Math.max(0, element.scrollHeight - element.clientHeight);
return range > 0 ? element.scrollTop / range : 0;
const top = Math.max(0, Math.min(range, element.scrollTop));
return {
ratio: range > 0 ? top / range : 0,
atStart: top <= 2,
atEnd: range > 0 && range - top <= 2,
};
}
function editorScrollAnchor() {
const state = scrollState(editor);
return {
sourceLine: 1 + Math.max(0, editor.scrollTop) / editorLineHeight(),
ratio: scrollRatio(editor),
...state,
};
}
@@ -900,8 +911,9 @@ export function startNoteEditor(adapter) {
}
function previewScrollAnchor() {
const state = scrollState(preview);
const positions = previewLinePositions();
if (!positions.length) return { sourceLine: null, ratio: scrollRatio(preview) };
if (!positions.length) return { sourceLine: null, ...state };
const paddingTop = parseFloat(getComputedStyle(preview).paddingTop) || 0;
const viewportTop = preview.scrollTop + paddingTop;
let currentIndex = positions.findIndex(position => position.bottom > viewportTop + 0.5);
@@ -912,7 +924,7 @@ export function startNoteEditor(adapter) {
const sourceLine = next
? current.sourceLine + progress * (next.sourceLine - current.sourceLine)
: current.sourceLine;
return { sourceLine, ratio: scrollRatio(preview) };
return { sourceLine, ...state };
}
function activeScrollAnchor(view = renderedView) {
@@ -925,7 +937,9 @@ export function startNoteEditor(adapter) {
}
function scrollEditorToAnchor(anchor) {
if (Number.isFinite(anchor?.sourceLine)) {
if (anchor?.atEnd) setScrollRatio(editor, 1);
else if (anchor?.atStart) setScrollRatio(editor, 0);
else if (Number.isFinite(anchor?.sourceLine)) {
const range = Math.max(0, editor.scrollHeight - editor.clientHeight);
editor.scrollTop = Math.max(0, Math.min(range, (anchor.sourceLine - 1) * editorLineHeight()));
} else setScrollRatio(editor, anchor?.ratio || 0);
@@ -933,6 +947,14 @@ export function startNoteEditor(adapter) {
}
function scrollPreviewToAnchor(anchor) {
if (anchor?.atEnd) {
setScrollRatio(preview, 1);
return;
}
if (anchor?.atStart) {
setScrollRatio(preview, 0);
return;
}
const positions = previewLinePositions();
if (!Number.isFinite(anchor?.sourceLine) || !positions.length) {
setScrollRatio(preview, anchor?.ratio || 0);
@@ -976,7 +998,62 @@ export function startNoteEditor(adapter) {
if (activeView() !== "split") return;
scrollPreviewToAnchor(editorScrollAnchor());
}
function renderNow() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); scheduleAliasFileRefresh(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); }
function capturePreviewViewport(target) {
if (!target || !preview.contains(target)) return null;
const sourceLine = Number(target.dataset.sourceLine);
const previewRect = preview.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
return {
sourceLine: Number.isFinite(sourceLine) ? sourceLine : null,
viewportOffset: targetRect.top - previewRect.top,
scrollTop: preview.scrollTop,
focused: document.activeElement === target,
};
}
function restorePreviewViewport(snapshot) {
if (!snapshot) return;
const range = Math.max(0, preview.scrollHeight - preview.clientHeight);
if (Number.isFinite(snapshot.sourceLine)) {
const target = preview.querySelector(`.task-checkbox[data-source-line="${snapshot.sourceLine}"]`)
|| preview.querySelector(`.preview-source-line[data-source-line="${snapshot.sourceLine}"]`);
if (target) {
const previewRect = preview.getBoundingClientRect();
const currentOffset = target.getBoundingClientRect().top - previewRect.top;
preview.scrollTop = Math.max(0, Math.min(range, preview.scrollTop + currentOffset - snapshot.viewportOffset));
if (snapshot.focused) target.focus({ preventScroll: true });
return;
}
}
preview.scrollTop = Math.max(0, Math.min(range, snapshot.scrollTop || 0));
}
function renderNow() {
const previewViewport = pendingPreviewViewport;
pendingPreviewViewport = null;
if (uiState.mode === "markdown") {
preview.classList.remove("preview--raw");
preview.innerHTML = renderMarkdown(editor.value);
scheduleAliasFileRefresh(editor.value);
document.querySelector("#preview-label").textContent = "Preview (media / mermaid / markdown)";
renderMermaid();
renderCodeHighlight();
renderMediaPlayers();
} else {
preview.classList.add("preview--raw");
preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).join("");
document.querySelector("#preview-label").textContent = "Text preview";
}
alignPreviewLineNumbers(preview);
document.querySelector("#characters").textContent = `${editor.value.length} characters`;
document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`;
renderGutter();
requestAnimationFrame(() => {
if (previewViewport) restorePreviewViewport(previewViewport);
else syncPreviewScroll();
});
}
const render = createRenderQueue(renderNow);
function queueCollaborativeOperation(operation, ownerReplacements = []) {
if (!collaboration.ready || !canEditDocument()) return false;
@@ -1066,6 +1143,16 @@ export function startNoteEditor(adapter) {
return singlePaneQuery.matches ? compactView : uiState.view;
}
function applyToolbarCollapsed() {
document.body.classList.toggle("toolbar-collapsed", toolbarCollapsed);
if (!toolbarCollapseToggle) return;
const label = toolbarCollapsed ? "Show editor toolbar" : "Hide editor toolbar";
toolbarCollapseToggle.setAttribute("aria-pressed", String(toolbarCollapsed));
toolbarCollapseToggle.setAttribute("aria-label", label);
toolbarCollapseToggle.title = label;
toolbarCollapseToggle.querySelector(".toolbar-collapse-toggle__icon").textContent = toolbarCollapsed ? "⌄" : "⌃";
}
function applyUi({ write = false, replace = false } = {}) {
const view = activeView();
renderedView = view;
@@ -1073,6 +1160,7 @@ export function startNoteEditor(adapter) {
editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`);
document.body.classList.toggle("compact-editor", compactToggle.checked);
document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches);
applyToolbarCollapsed();
document.querySelectorAll("[data-view]").forEach(button => {
const active = button.dataset.view === view;
button.classList.toggle("active", active);
@@ -1483,6 +1571,7 @@ export function startNoteEditor(adapter) {
if (event.key === "Escape" && mobileEditorOptions?.open) mobileEditorOptions.open = false;
});
modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUiPreservingScroll({ write: true }); });
toolbarCollapseToggle?.addEventListener("click", () => { toolbarCollapsed = !toolbarCollapsed; localStorage.setItem(notePreferenceKey("toolbar-collapsed"), toolbarCollapsed ? "on" : "off"); applyUiPreservingScroll(); scheduleEditorSettingsSave({ personal: true }); });
lineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-numbers"), lineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); });
previewLineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("preview-line-numbers"), previewLineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); alignPreviewLineNumbers(preview); scheduleEditorSettingsSave({ personal: true }); });
compactToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("compact"), compactToggle.checked ? "on" : "off"); syncMobileEditorControls(); applyUiPreservingScroll(); scheduleEditorSettingsSave({ personal: true }); });
@@ -1680,6 +1769,7 @@ export function startNoteEditor(adapter) {
editor_line_numbers: lineToggle.checked,
preview_line_numbers: previewLineToggle.checked,
line_links: lineLinksToggle.checked,
toolbar_collapsed: toolbarCollapsed,
font_family: fontFamily.value,
font_size: Number(fontSize.value),
};
@@ -1811,6 +1901,7 @@ export function startNoteEditor(adapter) {
const lineIndex = Number(checkbox.dataset.sourceLine) - 1;
const lines = editor.value.split("\n");
if (lineIndex < 0 || lineIndex >= lines.length) return;
pendingPreviewViewport = capturePreviewViewport(checkbox);
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 }));
+131 -21
View File
@@ -30,9 +30,18 @@ function formatDate(value) {
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("pl-PL");
}
function aliasCode(filename, label, mimeType) {
const kind = String(mimeType || "").startsWith("image/") ? "image" : "file";
function isVideo(mimeType) {
return String(mimeType || "").startsWith("video/");
}
function isLikelyVideoFile(file) {
return isVideo(file?.type) || /\.(?:mp4|m4v|mov|webm|ogv)$/i.test(String(file?.name || ""));
}
function aliasCode(filename, label, mimeType, mode = "auto") {
const safeLabel = String(label || filename).replace(/\]/g, ")").replace(/[\r\n]+/g, " ").trim() || filename;
let kind = String(mimeType || "").startsWith("image/") ? "image" : "file";
if (isVideo(mimeType) && mode === "player") kind = "video";
return `[${kind}=${filename},${safeLabel}]`;
}
@@ -70,12 +79,84 @@ function safeAttachmentUrl(value) {
: safePublicUrl(raw, { allowMailto: false });
}
function createVideoInsertDialog() {
const dialog = document.createElement("dialog");
dialog.className = "app-dialog video-insert-dialog";
dialog.innerHTML = `
<div class="video-insert-dialog__panel">
<div class="dialog-heading-row">
<div><p class="eyebrow">Video file</p><h2>How should it be added?</h2></div>
<button type="button" class="icon-button" data-video-choice="cancel" aria-label="Cancel">×</button>
</div>
<p class="dialog-copy" data-video-file-name></p>
<div class="video-choice-actions">
<button type="button" class="action-button action-button--primary" data-video-choice="player">
<strong>Embedded player</strong><span>Play the video directly in the note.</span>
</button>
<button type="button" class="action-button action-button--secondary" data-video-choice="download">
<strong>Download link</strong><span>Insert a link that downloads the original file.</span>
</button>
</div>
</div>`;
document.body.append(dialog);
return dialog;
}
export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxSize = () => 0, canDelete, canUpload, canEdit = () => true, toast, onFilesChanged = () => { } }) {
const dialog = document.querySelector("#files-dialog");
const list = document.querySelector("#files-list");
const input = document.querySelector("#file-input");
const footer = document.querySelector("#footer-files");
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
let videoInsertDialog;
function chooseVideoInsertMode(filename) {
videoInsertDialog ||= createVideoInsertDialog();
videoInsertDialog.querySelector("[data-video-file-name]").textContent = filename;
if (!videoInsertDialog.open) videoInsertDialog.showModal();
return new Promise(resolve => {
let settled = false;
const finish = value => {
if (settled) return;
settled = true;
videoInsertDialog.removeEventListener("click", handleClick);
videoInsertDialog.removeEventListener("cancel", handleCancel);
if (videoInsertDialog.open) videoInsertDialog.close();
resolve(value);
};
const handleClick = event => {
const choice = event.target.closest("[data-video-choice]")?.dataset.videoChoice;
if (!choice) return;
finish(choice === "player" || choice === "download" ? choice : null);
};
const handleCancel = event => {
event.preventDefault();
finish(null);
};
videoInsertDialog.addEventListener("click", handleClick);
videoInsertDialog.addEventListener("cancel", handleCancel);
});
}
function fileActionButtons(file) {
const attributes = `data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}"`;
const insertButtons = isVideo(file.mime_type)
? `<button class="action-button action-button--primary compact-button" data-add-file-to-note data-insert-mode="player" ${attributes}>Add player</button>
<button class="action-button action-button--secondary compact-button" data-add-file-to-note data-insert-mode="download" ${attributes}>Add download</button>`
: `<button class="action-button action-button--primary compact-button" data-add-file-to-note data-insert-mode="auto" ${attributes}>Add to note</button>`;
const codeButtons = isVideo(file.mime_type)
? `<button class="action-button action-button--secondary compact-button" data-show-file-code="link" ${attributes}>Link</button>
<button class="action-button action-button--secondary compact-button" data-show-file-code="player" ${attributes}>Player code</button>
<button class="action-button action-button--secondary compact-button" data-show-file-code="download" ${attributes}>Download code</button>`
: `<button class="action-button action-button--secondary compact-button" data-show-file-code="link" ${attributes}>Link</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="alias" ${attributes}>Alias</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="markdown" ${attributes}>Markdown</button>`;
const deleteButton = canDelete()
? `<button class="action-button action-button--danger compact-button" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>`
: "";
return `${codeButtons}${insertButtons}${deleteButton}`;
}
async function loadFiles({ open = false } = {}) {
try {
@@ -88,13 +169,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
<div class="file-name">${escapeHtml(file.filename)}</div>
<div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)}${file.created_at ? ` · ${formatDate(file.created_at)}` : ""} · <span class="file-flag${file.is_attached ? "" : " detached"}">${file.is_attached ? "in note" : "removed from content"}</span></div>
</div>
<div class="file-actions">
<button class="action-button action-button--secondary compact-button" data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="alias" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Alias</button>
<button class="action-button action-button--primary compact-button" data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button>
<button class="action-button action-button--primary compact-button" data-add-file-to-note data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Add to note</button>
${canDelete() ? `<button class="action-button action-button--danger compact-button" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>` : ""}
</div>
<div class="file-actions">${fileActionButtons(file)}</div>
<div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button class="action-button action-button--primary compact-button" data-copy-generated>Copy</button></div>
</div>`).join("") : '<p class="dialog-copy">No files uploaded.</p>';
onFilesChanged(files);
@@ -112,7 +187,15 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
return { start: start + text.length, end: start + text.length };
}
async function uploadFile(file, onUploaded) {
function insertVideoPlayer(text, range = null, inputType = "insertText") {
const start = Math.max(0, Math.min(range?.start ?? editor.selectionStart, editor.value.length));
const end = Math.max(start, Math.min(range?.end ?? editor.selectionEnd, editor.value.length));
const prefix = start > 0 && editor.value[start - 1] !== "\n" ? "\n" : "";
const suffix = end < editor.value.length && editor.value[end] !== "\n" ? "\n" : "";
return insertText(`${prefix}${text}${suffix}`, { start, end }, inputType);
}
async function uploadFile(file, onUploaded, insertMode = "auto") {
const uploadToast = createUploadToast(file.name);
let completed = false;
@@ -131,7 +214,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
});
if (completed) return;
completed = true;
const text = aliasCode(result.name, file.name, result.mime_type || file.type);
const text = aliasCode(result.name, file.name, result.mime_type || file.type, insertMode);
await onUploaded(text);
uploadToast.success();
await loadFiles();
@@ -158,20 +241,21 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
input.addEventListener("change", async event => {
let file = event.target.files[0];
if (!file) return;
input.value = "";
if (file.type.startsWith("image/")) {
try {
file = await prepareImageFile(file);
} catch (error) {
toast(error.message);
input.value = "";
return;
}
if (!file) { input.value = ""; return; }
if (!file) return;
}
const insertMode = isLikelyVideoFile(file) ? await chooseVideoInsertMode(file.name) : "auto";
if (!insertMode) return;
const range = { start: editor.selectionStart, end: editor.selectionEnd };
input.value = "";
await uploadFile(file, text => insertText(text, range));
await uploadFile(file, text => insertMode === "player" ? insertVideoPlayer(text, range) : insertText(text, range), insertMode);
});
document.querySelector("#editor-workspace")?.addEventListener("paste", async event => {
@@ -202,7 +286,10 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
state.replaceEnd = state.cursor;
};
for (const file of files) await uploadFile(file, insertPastedAlias);
for (const file of files) {
const insertMode = isLikelyVideoFile(file) ? await chooseVideoInsertMode(file.name) : "auto";
if (insertMode) await uploadFile(file, insertPastedAlias, insertMode);
}
editor.setSelectionRange(state.cursor, state.cursor);
});
@@ -212,11 +299,14 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
list.addEventListener("click", async event => {
const addButton = event.target.closest("[data-add-file-to-note]");
if (addButton) {
const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime);
insertText(text);
const mode = addButton.dataset.insertMode;
const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime, mode);
if (mode === "player") insertVideoPlayer(text);
else insertText(text);
toast("Added to note");
return;
}
const showButton = event.target.closest("[data-show-file-code]");
if (showButton) {
const panel = showButton.closest(".file-row").querySelector(".file-code");
@@ -228,11 +318,29 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime);
} else if (showButton.dataset.showFileCode === "markdown") {
text = markdownCode(safeUrl, showButton.dataset.name, showButton.dataset.mime);
} else if (showButton.dataset.showFileCode === "player") {
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime, "player");
} else if (showButton.dataset.showFileCode === "download") {
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime, "download");
}
output.value = text; panel.hidden = false; output.focus(); output.select(); return;
output.value = text;
panel.hidden = false;
output.focus();
output.select();
return;
}
const copyButton = event.target.closest("[data-copy-generated]");
if (copyButton) { try { await copyText(copyButton.closest(".file-code").querySelector("textarea").value); toast("Copied"); } catch (error) { toast(error.message); } return; }
if (copyButton) {
try {
await copyText(copyButton.closest(".file-code").querySelector("textarea").value);
toast("Copied");
} catch (error) {
toast(error.message);
}
return;
}
const deleteButton = event.target.closest("[data-delete-file]");
if (!deleteButton) return;
if (!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`, { title: "Delete file", confirmText: "Delete", danger: true })) return;
@@ -240,7 +348,9 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
await api(endpoints.remove(deleteButton.dataset.deleteFile), { method: "DELETE", headers: {}, body: JSON.stringify({ access_token: getAccessToken() || null }) });
toast("File deleted");
await loadFiles();
} catch (error) { toast(error.message); }
} catch (error) {
toast(error.message);
}
});
return { loadFiles };
+5 -3
View File
@@ -15,6 +15,7 @@ import { copyText } from "@rustpad/clipboard";
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles } from "@rustpad/markdown";
import { toast } from "@rustpad/toast";
import { getTheme } from "@rustpad/theme";
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
const token = location.pathname.split("/").filter(Boolean)[1];
const content = document.querySelector("#public-content");
@@ -22,8 +23,9 @@ const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle");
const passwordDialog = document.querySelector("#public-password-dialog"), passwordForm = document.querySelector("#public-password-form"), passwordInput = document.querySelector("#public-password"), passwordError = document.querySelector("#public-password-error");
let pagePassword = "";
function pageHeaders() { return pagePassword ? { "X-RustPad-Page-Password": pagePassword } : {}; }
async function renderMermaid() { const nodes = content.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: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } }
async function renderMermaid() { const nodes = content.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const mermaid = await loadMermaid(); mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await loadHighlight(); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } }
async function renderMediaPlayers() { const nodes = content.querySelectorAll("[data-rustpad-player]"); if (!nodes.length) return; try { const { hydrateMediaPlayers } = await loadMediaPlayer(); hydrateMediaPlayers(content); } catch { } }
function lockPublicContent(allowTaskUpdates) {
content.querySelectorAll('[contenteditable]').forEach(node => node.removeAttribute('contenteditable'));
content.querySelectorAll('.preview-editable').forEach(node => node.classList.remove('preview-editable'));
@@ -39,7 +41,7 @@ function scrollToPublicAnchor(hash, behavior = "auto") {
target.scrollIntoView({ behavior, block: "start" });
return true;
}
async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() }); if (passwordDialog.open) passwordDialog.close(); passwordError.textContent = ""; document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; setMarkdownFiles(page.files || []); content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { if (error.status === 401 || error.status === 403) { passwordError.textContent = error.status === 403 ? "Sign in with an authorized account or enter the resource password." : "Enter the correct password."; if (!passwordDialog.open) passwordDialog.showModal(); passwordInput.focus(); return; } content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } }
async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() }); if (passwordDialog.open) passwordDialog.close(); passwordError.textContent = ""; document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; setMarkdownFiles(page.files || []); content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight(), renderMediaPlayers()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { if (error.status === 401 || error.status === 403) { passwordError.textContent = error.status === 403 ? "Sign in with an authorized account or enter the resource password." : "Enter the correct password."; if (!passwordDialog.open) passwordDialog.showModal(); passwordInput.focus(); return; } content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } }
content.addEventListener("click", event => {
const link = event.target.closest('.markdown-toc a[href^="#"]');
if (!link) return;
+53
View File
@@ -0,0 +1,53 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*/
const config = window.__RUSTPAD_CONFIG__ || {};
const assetVersion = encodeURIComponent(String(config.assetVersion || "dev"));
const promiseCache = new Map();
function localAsset(path) {
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}v=${assetVersion}`;
}
function once(key, factory) {
if (!promiseCache.has(key)) promiseCache.set(key, Promise.resolve().then(factory));
return promiseCache.get(key);
}
function loadClassicScript(path, globalName) {
return once(path, () => new Promise((resolve, reject) => {
const existing = document.querySelector(`script[data-rustpad-lib="${globalName}"]`);
if (existing && window[globalName]) {
resolve(window[globalName]);
return;
}
const script = existing || document.createElement("script");
script.src = localAsset(path);
script.async = true;
script.dataset.rustpadLib = globalName;
script.addEventListener("load", () => {
if (window[globalName]) resolve(window[globalName]);
else reject(new Error(`${globalName} did not register a browser global.`));
}, { once: true });
script.addEventListener("error", () => reject(new Error(`Failed to load ${path}.`)), { once: true });
if (!existing) document.head.append(script);
}));
}
export function loadHighlight() {
return loadClassicScript("/assets/libs/highlight/highlight.min.js", "hljs");
}
export function loadMermaid() {
return once("mermaid", async () => {
const module = await import(localAsset("/assets/libs/mermaid/mermaid.esm.min.mjs"));
return module.default;
});
}
export function loadMediaPlayer() {
return once("rustpad-player", () => import(localAsset("/assets/libs/rustpad-player/player.js")));
}
+18
View File
@@ -0,0 +1,18 @@
# Browser libraries
Only `rustpad-player/` belongs to the repository. Mermaid and Highlight.js are generated directories and must not be committed.
Run:
```bash
python3 scripts/update_browser_libs.py
```
The updater resolves the current stable npm versions, verifies package integrity, copies the required browser assets and licenses, and atomically replaces:
- `static/libs/mermaid/`
- `static/libs/highlight/`
`./dev.sh` runs the updater before local Cargo development. Docker downloads the same libraries in the `browser-libs` build stage. The application lazy-loads them only when matching content is rendered.
Project-owned media helpers remain in `static/libs/rustpad-player/`.
+56
View File
@@ -0,0 +1,56 @@
.rustpad-media {
width: min(100%, 960px);
margin: 18px auto;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 12px;
background: #000;
box-shadow: 0 12px 30px color-mix(in srgb, #000 22%, transparent);
}
.rustpad-media__player,
.rustpad-media__player iframe,
.rustpad-media__player video,
video.rustpad-media__player {
display: block;
width: 100%;
max-width: 100%;
}
.rustpad-media__player[data-player-kind="youtube"] {
position: relative;
aspect-ratio: 16 / 9;
}
.rustpad-media__player[data-player-kind="youtube"] iframe {
position: absolute;
inset: 0;
height: 100%;
border: 0;
}
video.rustpad-media__player,
.rustpad-media__player video {
max-height: min(72vh, 760px);
background: #000;
}
.rustpad-media__fallback {
margin: 0;
padding: 10px 12px;
border-top: 1px solid color-mix(in srgb, white 18%, transparent);
background: var(--surface-deep);
color: var(--muted);
font-size: .78rem;
}
.rustpad-media__fallback a {
color: var(--accent-soft);
}
@media (max-width: 600px) {
.rustpad-media {
margin-block: 14px;
border-radius: 9px;
}
}
+47
View File
@@ -0,0 +1,47 @@
/*
* RustPad media player helpers.
* Project-owned code: keeps native video playback and YouTube embeds responsive.
*/
const initialized = new WeakSet();
function createYouTubeFrame(node) {
const videoId = String(node.dataset.videoId || "").trim();
if (!/^[A-Za-z0-9_-]{6,20}$/.test(videoId)) return;
const iframe = document.createElement("iframe");
iframe.src = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(videoId)}?rel=0&modestbranding=1&playsinline=1`;
iframe.title = node.dataset.playerTitle || "YouTube video";
iframe.loading = "lazy";
iframe.allow = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share";
iframe.referrerPolicy = "strict-origin-when-cross-origin";
iframe.allowFullscreen = true;
node.replaceChildren(iframe);
}
function initializeNativeVideo(video) {
video.controls = true;
video.playsInline = true;
video.preload = video.preload || "metadata";
const fallback = video.closest(".rustpad-media")?.querySelector(".rustpad-media__fallback");
if (!fallback) return;
const revealFallback = () => { fallback.hidden = false; };
video.addEventListener("error", revealFallback, { once: true });
video.querySelectorAll("source").forEach(source => source.addEventListener("error", revealFallback, { once: true }));
}
export function hydrateMediaPlayers(root = document) {
root.querySelectorAll("[data-rustpad-player]").forEach(node => {
if (initialized.has(node)) return;
initialized.add(node);
if (node.dataset.playerKind === "youtube") createYouTubeFrame(node);
else if (node instanceof HTMLVideoElement) initializeNativeVideo(node);
});
}
export function destroyMediaPlayers(root = document) {
root.querySelectorAll('[data-rustpad-player][data-player-kind="youtube"] iframe').forEach(frame => {
frame.src = "about:blank";
});
}