new functions and fixes
This commit is contained in:
@@ -13,3 +13,7 @@ docker-compose*.yml
|
||||
migrate/
|
||||
scripts/*.txt
|
||||
tests/
|
||||
|
||||
# Downloaded in the browser-libs Docker stage
|
||||
static/libs/mermaid/
|
||||
static/libs/highlight/
|
||||
|
||||
@@ -15,3 +15,7 @@ venv
|
||||
migrate/etherpad-dry-run-report.json
|
||||
data/garage
|
||||
scripts/*.txt
|
||||
|
||||
# Generated by scripts/update_browser_libs.py
|
||||
/static/libs/mermaid/
|
||||
/static/libs/highlight/
|
||||
|
||||
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.2.40"
|
||||
version = "0.2.42"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Executable
+365
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = ?"#
|
||||
|
||||
@@ -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"#
|
||||
|
||||
@@ -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
@@ -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(()));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
+312
-31
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -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/`.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user