new functions and fixes
This commit is contained in:
+5
-1
@@ -12,4 +12,8 @@ Dockerfile*
|
|||||||
docker-compose*.yml
|
docker-compose*.yml
|
||||||
migrate/
|
migrate/
|
||||||
scripts/*.txt
|
scripts/*.txt
|
||||||
tests/
|
tests/
|
||||||
|
|
||||||
|
# Downloaded in the browser-libs Docker stage
|
||||||
|
static/libs/mermaid/
|
||||||
|
static/libs/highlight/
|
||||||
|
|||||||
+5
-1
@@ -14,4 +14,8 @@ venv
|
|||||||
.venv
|
.venv
|
||||||
migrate/etherpad-dry-run-report.json
|
migrate/etherpad-dry-run-report.json
|
||||||
data/garage
|
data/garage
|
||||||
scripts/*.txt
|
scripts/*.txt
|
||||||
|
|
||||||
|
# Generated by scripts/update_browser_libs.py
|
||||||
|
/static/libs/mermaid/
|
||||||
|
/static/libs/highlight/
|
||||||
|
|||||||
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.40"
|
version = "0.2.42"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.40"
|
version = "0.2.42"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.94"
|
rust-version = "1.94"
|
||||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
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
|
FROM rust:slim-trixie AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -5,6 +13,8 @@ COPY Cargo.toml Cargo.lock ./
|
|||||||
COPY migrations ./migrations
|
COPY migrations ./migrations
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY static ./static
|
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
|
RUN cargo build --release
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ RustPad is a collaborative Markdown editor with standalone notes and workspaces.
|
|||||||
./dev.sh
|
./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
|
## Workspace features
|
||||||
|
|
||||||
@@ -16,10 +16,12 @@ The script creates `data/db` and `data/files`, builds the project, and starts it
|
|||||||
- Nicknames stored in `localStorage`.
|
- Nicknames stored in `localStorage`.
|
||||||
- Change authors shown in history.
|
- Change authors shown in history.
|
||||||
- Line numbering enabled by default, with per-account preferences stored separately for each note or pad.
|
- 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.
|
- 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.
|
- Owner color displayed next to each line.
|
||||||
- Image and file uploads to `data/files/pads/<id>_<token>/` or `data/files/notes/<id>_<token>/`.
|
- 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.
|
- Markdown and Mermaid diagram rendering.
|
||||||
- History with snippets, previews, and version restore.
|
- History with snippets, previews, and version restore.
|
||||||
- Alert blocks: `success`, `info`, `warning`, and `danger`.
|
- 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
|
## 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.
|
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.
|
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
|
## Diagnostics and logging
|
||||||
|
|
||||||
Server logs use `tracing`. Configure verbosity with `RUST_LOG`, for example:
|
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.
|
# Generate a new asset version on each run to prevent stale HTML and JavaScript.
|
||||||
export ASSET_VERSION="${ASSET_VERSION:-dev-$(date +%s)}"
|
export ASSET_VERSION="${ASSET_VERSION:-dev-$(date +%s)}"
|
||||||
|
|
||||||
if command -v cargo >/dev/null 2>&1; then
|
update_browser_libs() {
|
||||||
echo "Cleaning RustPad build artifacts..."
|
local python_bin=""
|
||||||
cargo clean --package rustpad
|
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..."
|
if [[ -z "$python_bin" ]]; then
|
||||||
exec cargo run --package rustpad
|
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
|
fi
|
||||||
|
|
||||||
if command -v docker >/dev/null 2>&1; then
|
if command -v docker >/dev/null 2>&1; then
|
||||||
export IMAGE_TAG="${IMAGE_TAG:-dev}"
|
export IMAGE_TAG="${IMAGE_TAG:-dev}"
|
||||||
|
export BROWSER_LIBS_REFRESH="${BROWSER_LIBS_REFRESH:-dev-$(date +%s)}"
|
||||||
|
|
||||||
echo "Starting RustPad with Docker..."
|
echo "Starting RustPad with Docker..."
|
||||||
exec docker compose up --build --force-recreate --remove-orphans
|
exec docker compose up --build --force-recreate --remove-orphans
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
BROWSER_LIBS_REFRESH: ${BROWSER_LIBS_REFRESH:-manual}
|
||||||
image: rustpad:${IMAGE_TAG:-local}
|
image: rustpad:${IMAGE_TAG:-local}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
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) {
|
if content.contains(url) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
for marker in ["[file=", "[image=", "[img="] {
|
for marker in ["[file=", "[image=", "[img=", "[video="] {
|
||||||
let mut remaining = content;
|
let mut remaining = content;
|
||||||
while let Some(index) = remaining.find(marker) {
|
while let Some(index) = remaining.find(marker) {
|
||||||
let after = &remaining[index + marker.len()..];
|
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."))
|
.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(
|
pub async fn download_file(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Path((token, filename)): Path<(String, String)>,
|
Path((token, filename)): Path<(String, String)>,
|
||||||
|
Query(query): Query<FileDownloadQuery>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> 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(
|
async fn serve_token_file(
|
||||||
state: &SharedState,
|
state: &SharedState,
|
||||||
|
headers: &HeaderMap,
|
||||||
token: &str,
|
token: &str,
|
||||||
filename: &str,
|
filename: &str,
|
||||||
|
force_download: bool,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let safe = sanitize_filename(filename);
|
let safe = sanitize_filename(filename);
|
||||||
if safe != filename {
|
if safe != filename {
|
||||||
@@ -543,15 +564,44 @@ async fn serve_token_file(
|
|||||||
.get_local_with_legacy(&key, &legacy_key)
|
.get_local_with_legacy(&key, &legacy_key)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::not_found_file())?;
|
.map_err(|_| ApiError::not_found_file())?;
|
||||||
|
let total_len = bytes.len();
|
||||||
let guessed_mime = mime_guess::from_path(&safe).first_or_octet_stream();
|
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 safe_inline = is_safe_inline_image_mime(guessed_mime.essence_str())
|
||||||
let served_mime = if inline_image {
|
|| is_safe_inline_video_mime(guessed_mime.essence_str());
|
||||||
|
let served_mime = if safe_inline {
|
||||||
guessed_mime.as_ref()
|
guessed_mime.as_ref()
|
||||||
} else {
|
} else {
|
||||||
"application/octet-stream"
|
"application/octet-stream"
|
||||||
};
|
};
|
||||||
let disposition = if inline_image { "inline" } else { "attachment" };
|
let disposition = if safe_inline && !force_download {
|
||||||
let mut response = bytes.into_response();
|
"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(
|
response.headers_mut().insert(
|
||||||
header::CONTENT_TYPE,
|
header::CONTENT_TYPE,
|
||||||
HeaderValue::from_str(served_mime)
|
HeaderValue::from_str(served_mime)
|
||||||
@@ -562,6 +612,20 @@ async fn serve_token_file(
|
|||||||
HeaderValue::from_str(&format!("{disposition}; filename=\"{safe}\""))
|
HeaderValue::from_str(&format!("{disposition}; filename=\"{safe}\""))
|
||||||
.expect("sanitized attachment filename"),
|
.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(
|
response.headers_mut().insert(
|
||||||
HeaderName::from_static("content-security-policy"),
|
HeaderName::from_static("content-security-policy"),
|
||||||
HeaderValue::from_static("default-src 'none'; sandbox"),
|
HeaderValue::from_static("default-src 'none'; sandbox"),
|
||||||
@@ -584,6 +648,52 @@ async fn serve_token_file(
|
|||||||
Ok(response)
|
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 {
|
fn is_safe_inline_image_mime(value: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
value,
|
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 {
|
fn sanitize_filename(value: &str) -> String {
|
||||||
let name = std::path::Path::new(value)
|
let name = std::path::Path::new(value)
|
||||||
.file_name()
|
.file_name()
|
||||||
|
|||||||
@@ -432,6 +432,7 @@ pub struct NoteInfo {
|
|||||||
editor_line_numbers: bool,
|
editor_line_numbers: bool,
|
||||||
preview_line_numbers: bool,
|
preview_line_numbers: bool,
|
||||||
line_links: bool,
|
line_links: bool,
|
||||||
|
toolbar_collapsed: bool,
|
||||||
font_family: String,
|
font_family: String,
|
||||||
font_size: i64,
|
font_size: i64,
|
||||||
personal_editor_settings: bool,
|
personal_editor_settings: bool,
|
||||||
@@ -461,6 +462,8 @@ pub struct EditorSettingsRequest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
line_links: Option<bool>,
|
line_links: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
toolbar_collapsed: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
font_family: Option<String>,
|
font_family: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
font_size: Option<i64>,
|
font_size: Option<i64>,
|
||||||
@@ -502,6 +505,7 @@ async fn save_editor_settings(
|
|||||||
|| payload.editor_line_numbers.is_some()
|
|| payload.editor_line_numbers.is_some()
|
||||||
|| payload.preview_line_numbers.is_some()
|
|| payload.preview_line_numbers.is_some()
|
||||||
|| payload.line_links.is_some()
|
|| payload.line_links.is_some()
|
||||||
|
|| payload.toolbar_collapsed.is_some()
|
||||||
|| payload.font_family.is_some()
|
|| payload.font_family.is_some()
|
||||||
|| payload.font_size.is_some();
|
|| payload.font_size.is_some();
|
||||||
let wants_global_update = payload.authorship_mode.is_some() || payload.colors_enabled.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 {
|
if let Some(value) = payload.line_links {
|
||||||
preferences.line_links = value;
|
preferences.line_links = value;
|
||||||
}
|
}
|
||||||
|
if let Some(value) = payload.toolbar_collapsed {
|
||||||
|
preferences.toolbar_collapsed = value;
|
||||||
|
}
|
||||||
if let Some(value) = payload.font_family {
|
if let Some(value) = payload.font_family {
|
||||||
preferences.font_family = match value.as_str() {
|
preferences.font_family = match value.as_str() {
|
||||||
"mono" | "system" | "serif" | "arial" | "georgia" => value,
|
"mono" | "system" | "serif" | "arial" | "georgia" => value,
|
||||||
@@ -1084,6 +1091,7 @@ pub async fn note_info(
|
|||||||
editor_line_numbers: editor_preferences.editor_line_numbers,
|
editor_line_numbers: editor_preferences.editor_line_numbers,
|
||||||
preview_line_numbers: editor_preferences.preview_line_numbers,
|
preview_line_numbers: editor_preferences.preview_line_numbers,
|
||||||
line_links: editor_preferences.line_links,
|
line_links: editor_preferences.line_links,
|
||||||
|
toolbar_collapsed: editor_preferences.toolbar_collapsed,
|
||||||
font_family: editor_preferences.font_family,
|
font_family: editor_preferences.font_family,
|
||||||
font_size: editor_preferences.font_size,
|
font_size: editor_preferences.font_size,
|
||||||
personal_editor_settings,
|
personal_editor_settings,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ pub struct PadInfo {
|
|||||||
editor_line_numbers: bool,
|
editor_line_numbers: bool,
|
||||||
preview_line_numbers: bool,
|
preview_line_numbers: bool,
|
||||||
line_links: bool,
|
line_links: bool,
|
||||||
|
toolbar_collapsed: bool,
|
||||||
font_family: String,
|
font_family: String,
|
||||||
font_size: i64,
|
font_size: i64,
|
||||||
personal_editor_settings: bool,
|
personal_editor_settings: bool,
|
||||||
@@ -172,6 +173,7 @@ pub async fn pad_info(
|
|||||||
editor_line_numbers: editor_preferences.editor_line_numbers,
|
editor_line_numbers: editor_preferences.editor_line_numbers,
|
||||||
preview_line_numbers: editor_preferences.preview_line_numbers,
|
preview_line_numbers: editor_preferences.preview_line_numbers,
|
||||||
line_links: editor_preferences.line_links,
|
line_links: editor_preferences.line_links,
|
||||||
|
toolbar_collapsed: editor_preferences.toolbar_collapsed,
|
||||||
font_family: editor_preferences.font_family,
|
font_family: editor_preferences.font_family,
|
||||||
font_size: editor_preferences.font_size,
|
font_size: editor_preferences.font_size,
|
||||||
personal_editor_settings,
|
personal_editor_settings,
|
||||||
|
|||||||
+21
-7
@@ -38,6 +38,7 @@ const MODULES: &[&str] = &[
|
|||||||
"toast",
|
"toast",
|
||||||
"theme",
|
"theme",
|
||||||
"url-state",
|
"url-state",
|
||||||
|
"vendor-libs",
|
||||||
"security",
|
"security",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -51,10 +52,20 @@ pub fn render_html(
|
|||||||
entrypoint: &str,
|
entrypoint: &str,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let urls = AssetUrls::new(asset_version);
|
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
|
let html = template
|
||||||
.replace("__APP_THEME_BOOTSTRAP__", theme_bootstrap())
|
.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_IMPORT_MAP__", &urls.import_map())
|
||||||
.replace("__APP_ENTRYPOINT__", &urls.entrypoint(entrypoint))
|
.replace("__APP_ENTRYPOINT__", &urls.entrypoint(entrypoint))
|
||||||
.replace(
|
.replace(
|
||||||
@@ -92,12 +103,14 @@ fn frontend_config(
|
|||||||
frontend_log_level: &str,
|
frontend_log_level: &str,
|
||||||
upload_max_size_bytes: usize,
|
upload_max_size_bytes: usize,
|
||||||
external_auth: bool,
|
external_auth: bool,
|
||||||
|
asset_version: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
format!(
|
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),
|
escape_js_string(frontend_log_level),
|
||||||
upload_max_size_bytes,
|
upload_max_size_bytes,
|
||||||
external_auth,
|
external_auth,
|
||||||
|
escape_js_string(asset_version),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,10 +128,11 @@ impl<'a> AssetUrls<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn stylesheet(&self, name: &str) -> String {
|
fn stylesheet(&self, name: &str) -> String {
|
||||||
format!(
|
self.stylesheet_path(&format!("css/{name}.css"))
|
||||||
r#"<link rel="stylesheet" href="{}">"#,
|
}
|
||||||
self.url(&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 {
|
fn entrypoint(&self, name: &str) -> String {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub struct EditorPreferences {
|
|||||||
pub editor_line_numbers: bool,
|
pub editor_line_numbers: bool,
|
||||||
pub preview_line_numbers: bool,
|
pub preview_line_numbers: bool,
|
||||||
pub line_links: bool,
|
pub line_links: bool,
|
||||||
|
pub toolbar_collapsed: bool,
|
||||||
pub font_family: String,
|
pub font_family: String,
|
||||||
pub font_size: i64,
|
pub font_size: i64,
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,7 @@ impl Default for EditorPreferences {
|
|||||||
editor_line_numbers: true,
|
editor_line_numbers: true,
|
||||||
preview_line_numbers: false,
|
preview_line_numbers: false,
|
||||||
line_links: false,
|
line_links: false,
|
||||||
|
toolbar_collapsed: false,
|
||||||
font_family: "mono".into(),
|
font_family: "mono".into(),
|
||||||
font_size: 14,
|
font_size: 14,
|
||||||
}
|
}
|
||||||
@@ -92,8 +94,9 @@ pub async fn load_editor_preferences(
|
|||||||
editor_line_numbers: row.try_get::<i64, _>(1)? != 0,
|
editor_line_numbers: row.try_get::<i64, _>(1)? != 0,
|
||||||
preview_line_numbers: row.try_get::<i64, _>(2)? != 0,
|
preview_line_numbers: row.try_get::<i64, _>(2)? != 0,
|
||||||
line_links: row.try_get::<i64, _>(3)? != 0,
|
line_links: row.try_get::<i64, _>(3)? != 0,
|
||||||
font_family: crate::row_decode::text(&row, 4)?,
|
toolbar_collapsed: row.try_get::<i64, _>(4)? != 0,
|
||||||
font_size: row.try_get(5)?,
|
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.editor_line_numbers)
|
||||||
.bind(preferences.preview_line_numbers)
|
.bind(preferences.preview_line_numbers)
|
||||||
.bind(preferences.line_links)
|
.bind(preferences.line_links)
|
||||||
|
.bind(preferences.toolbar_collapsed)
|
||||||
.bind(&preferences.font_family)
|
.bind(&preferences.font_family)
|
||||||
.bind(preferences.font_size)
|
.bind(preferences.font_size)
|
||||||
.execute(&mut *tx)
|
.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 = ?"#
|
r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_SELECT_PAD => {
|
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 => {
|
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 => {
|
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 => {
|
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 => {
|
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
|
||||||
r#"DELETE FROM user_editor_preferences WHERE user_id = ?"#
|
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"#
|
r#"DELETE FROM resource_editor_settings WHERE resource_kind = $1 AND resource_slug = $2"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_SELECT_PAD => {
|
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 => {
|
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 => {
|
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 => {
|
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 => {
|
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
|
||||||
r#"DELETE FROM user_editor_preferences WHERE user_id = $1"#
|
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 = ?"#
|
r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#
|
||||||
}
|
}
|
||||||
Query::EDITOR_PREFERENCES_SELECT_PAD => {
|
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 => {
|
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 => {
|
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 => {
|
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 => {
|
Query::EDITOR_PREFERENCES_DELETE_BY_USER => {
|
||||||
r#"DELETE FROM user_editor_preferences WHERE user_id = ?"#
|
r#"DELETE FROM user_editor_preferences WHERE user_id = ?"#
|
||||||
|
|||||||
+31
-1
@@ -7,7 +7,10 @@
|
|||||||
* See LICENSE file in repository root for details.
|
* 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]
|
#[test]
|
||||||
fn only_raster_images_are_inline() {
|
fn only_raster_images_are_inline() {
|
||||||
@@ -48,3 +51,30 @@ fn attachment_references_survive_origin_changes() {
|
|||||||
Some("https://new-files.example.com"),
|
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.
|
// MySQL BOOLEAN is TINYINT(1), which sqlx::Any 0.8 cannot map directly.
|
||||||
for (query, expected_casts) in [
|
for (query, expected_casts) in [
|
||||||
(Query::RESOURCE_EDITOR_SETTINGS_SELECT, 1),
|
(Query::RESOURCE_EDITOR_SETTINGS_SELECT, 1),
|
||||||
(Query::EDITOR_PREFERENCES_SELECT_PAD, 4),
|
(Query::EDITOR_PREFERENCES_SELECT_PAD, 5),
|
||||||
(Query::EDITOR_PREFERENCES_SELECT_NOTE, 4),
|
(Query::EDITOR_PREFERENCES_SELECT_NOTE, 5),
|
||||||
(Query::AUTH_USER_BY_EXTERNAL_ID, 1),
|
(Query::AUTH_USER_BY_EXTERNAL_ID, 1),
|
||||||
(Query::AUTH_USER_BY_SESSION, 1),
|
(Query::AUTH_USER_BY_SESSION, 1),
|
||||||
(Query::AUTH_USER_BY_NICKNAME, 1),
|
(Query::AUTH_USER_BY_NICKNAME, 1),
|
||||||
|
|||||||
+313
-32
@@ -831,6 +831,93 @@ textarea:focus {
|
|||||||
color: var(--text);
|
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 {
|
.workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 0;
|
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 {
|
.notes-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3299,6 +3449,39 @@ dialog::backdrop {
|
|||||||
grid-column: 1 / -1;
|
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 {
|
.profile-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -3406,21 +3589,42 @@ dialog::backdrop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#profile-dialog {
|
#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-panel,
|
||||||
#profile-dialog .identity-fields,
|
#profile-dialog .identity-fields,
|
||||||
.profile-actions {
|
#profile-dialog .profile-actions,
|
||||||
grid-template-columns: 1fr;
|
#profile-dialog .theme-options {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
#profile-dialog .identity-fields>*,
|
#profile-dialog .identity-fields > *,
|
||||||
#profile-dialog .profile-actions,
|
#profile-dialog .profile-actions,
|
||||||
#profile-dialog .form-message {
|
#profile-dialog .form-message {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#profile-dialog .profile-actions > button {
|
||||||
|
min-width: 0;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.identity-links {
|
.identity-links {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -3567,6 +3771,10 @@ dialog::backdrop {
|
|||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-action-label--short {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-password-menu {
|
.resource-password-menu {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -3636,16 +3844,6 @@ dialog::backdrop {
|
|||||||
background: var(--danger-subtle-bg);
|
background: var(--danger-subtle-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width:640px) {
|
|
||||||
.resource-row {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-actions {
|
|
||||||
width: 100%
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-main {
|
.resource-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3695,18 +3893,6 @@ dialog::backdrop {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.resource-main {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-actions {
|
|
||||||
width: 100%;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-row {
|
.resource-row {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -4457,6 +4643,81 @@ dialog::backdrop {
|
|||||||
background: color-mix(in srgb, var(--accent) 4%, transparent);
|
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) {
|
@media (max-width: 640px) {
|
||||||
|
|
||||||
.share-dialog,
|
.share-dialog,
|
||||||
@@ -5211,7 +5472,7 @@ dialog::backdrop {
|
|||||||
|
|
||||||
.pad-page .editor-toolbar {
|
.pad-page .editor-toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
@@ -5235,6 +5496,8 @@ dialog::backdrop {
|
|||||||
|
|
||||||
.pad-page #mode-toggle {
|
.pad-page #mode-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
@@ -5242,6 +5505,12 @@ dialog::backdrop {
|
|||||||
white-space: nowrap;
|
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(>.emoji-picker[open]),
|
||||||
.pad-page .toolbar-group:has(>.markdown-more[open]) {
|
.pad-page .toolbar-group:has(>.markdown-more[open]) {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
@@ -5251,6 +5520,8 @@ dialog::backdrop {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
grid-column: 4;
|
||||||
|
grid-row: 1;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
@@ -5298,7 +5569,7 @@ dialog::backdrop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pad-page .editor-toolbar {
|
.pad-page .editor-toolbar {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6741,7 +7012,7 @@ dialog::backdrop {
|
|||||||
|
|
||||||
@media (max-width: 760px) and (orientation: portrait) {
|
@media (max-width: 760px) and (orientation: portrait) {
|
||||||
.pad-page .editor-toolbar {
|
.pad-page .editor-toolbar {
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
}
|
}
|
||||||
@@ -6948,11 +7219,16 @@ dialog::backdrop {
|
|||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pad-page .view-switch {
|
.pad-page .toolbar-collapse-toggle {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pad-page .view-switch {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.pad-page .mobile-upload-button {
|
.pad-page .mobile-upload-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
@@ -6989,8 +7265,13 @@ dialog::backdrop {
|
|||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pad-page .view-switch {
|
.pad-page .toolbar-collapse-toggle {
|
||||||
grid-column: 3;
|
grid-column: 3;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
.pad-page .view-switch {
|
||||||
|
grid-column: 4;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+5
-2
@@ -117,7 +117,7 @@
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<button id="mobile-upload-button" class="mobile-upload-button" type="button"
|
<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">
|
<div class="editor-controls"><label>Font<select id="font-family">
|
||||||
<option value="mono">Mono</option>
|
<option value="mono">Mono</option>
|
||||||
<option value="system">System</option>
|
<option value="system">System</option>
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
<option value="20">20</option>
|
<option value="20">20</option>
|
||||||
<option value="22">22</option>
|
<option value="22">22</option>
|
||||||
</select></label></div><button id="upload-button"
|
</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
|
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">
|
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>
|
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
|
aria-pressed="true" aria-label="Markdown" title="Markdown"><span
|
||||||
class="control-label-full">Markdown</span><span class="control-label-short"
|
class="control-label-full">Markdown</span><span class="control-label-short"
|
||||||
aria-hidden="true">M</span></button>
|
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"
|
<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
|
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"
|
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 authHeaders() { return {}; }
|
||||||
function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; }
|
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 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 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()}`; }
|
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
|
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>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>`;
|
: `<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 inline = row.querySelector("[data-inline]");
|
||||||
const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; };
|
const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; };
|
||||||
@@ -175,7 +191,7 @@ async function loadResources() {
|
|||||||
try {
|
try {
|
||||||
await api("/api/auth/resources/privacy", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, private: nextPrivate }) });
|
await api("/api/auth/resources/privacy", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, private: nextPrivate }) });
|
||||||
item.private = nextPrivate;
|
item.private = nextPrivate;
|
||||||
button.textContent = nextPrivate ? "Make public" : "Make private";
|
setResourcePrivacyLabel(button, nextPrivate);
|
||||||
const meta = row.querySelector(".resource-copy small");
|
const meta = row.querySelector(".resource-copy small");
|
||||||
meta.textContent = `${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}`;
|
meta.textContent = `${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}`;
|
||||||
} catch (e) {
|
} 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) {
|
export function setMarkdownFiles(files) {
|
||||||
const normalized = (Array.isArray(files) ? files : [])
|
const normalized = (Array.isArray(files) ? files : [])
|
||||||
.filter(file => file && file.filename && file.url)
|
.filter(file => file && file.filename && file.url)
|
||||||
@@ -60,7 +105,7 @@ export function setMarkdownFiles(files) {
|
|||||||
export function unresolvedMarkdownFileAliases(value) {
|
export function unresolvedMarkdownFileAliases(value) {
|
||||||
const missing = new Set();
|
const missing = new Set();
|
||||||
const source = String(value || "").replace(/`[^`]*`/g, "");
|
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]);
|
if (!markdownFiles.has(match[1])) missing.add(match[1]);
|
||||||
}
|
}
|
||||||
return [...missing];
|
return [...missing];
|
||||||
@@ -76,13 +121,18 @@ function inline(value) {
|
|||||||
let html = escapeHtml(value);
|
let html = escapeHtml(value);
|
||||||
|
|
||||||
html = html.replace(/`([^`]+)`/g, (_, code) => stash(`<code>${code}</code>`));
|
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 normalizedKind = kind.toLowerCase();
|
||||||
const file = markdownFiles.get(filename);
|
const file = markdownFiles.get(filename);
|
||||||
if (!file) return match;
|
if (!file) return match;
|
||||||
if (normalizedKind === "file") {
|
if (normalizedKind === "file") {
|
||||||
const text = String(label || filename).trim() || filename;
|
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;
|
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 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);
|
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) {
|
function listLine(line) {
|
||||||
const match = line.match(/^(\s*)([-*+]|(\d+)\.)\s+(?:\[([ xX])\]\s+)?(.+)$/);
|
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; }
|
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;
|
const delimiter = index + 1 < lines.length ? tableDelimiter(lines[index + 1]) : null;
|
||||||
if (line.includes("|") && delimiter) {
|
if (line.includes("|") && delimiter) {
|
||||||
closeList();
|
closeList();
|
||||||
|
|||||||
+101
-10
@@ -28,10 +28,11 @@ import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url
|
|||||||
import { toast } from "@rustpad/toast";
|
import { toast } from "@rustpad/toast";
|
||||||
import { getTheme } from "@rustpad/theme";
|
import { getTheme } from "@rustpad/theme";
|
||||||
import { isResourceAccessError } from "@rustpad/security";
|
import { isResourceAccessError } from "@rustpad/security";
|
||||||
|
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
|
||||||
|
|
||||||
export function startNoteEditor(adapter) {
|
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 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 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");
|
const saveState = document.querySelector("#save-state");
|
||||||
editor.readOnly = true;
|
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 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 shareToken = new URLSearchParams(location.search).get("share");
|
||||||
const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`;
|
const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`;
|
||||||
|
let toolbarCollapsed = localStorage.getItem(notePreferenceKey("toolbar-collapsed")) === "on";
|
||||||
const collaborationClientId = typeof crypto.randomUUID === "function"
|
const collaborationClientId = typeof crypto.randomUUID === "function"
|
||||||
? crypto.randomUUID().replaceAll("-", "")
|
? crypto.randomUUID().replaceAll("-", "")
|
||||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||||
const collaboration = new CollaborationSession(collaborationClientId);
|
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 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 editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
|
||||||
|
let pendingPreviewViewport = null;
|
||||||
const editHistory = {
|
const editHistory = {
|
||||||
entries: [],
|
entries: [],
|
||||||
index: -1,
|
index: -1,
|
||||||
@@ -230,6 +233,7 @@ export function startNoteEditor(adapter) {
|
|||||||
lineToggle.checked = info.editor_line_numbers !== false;
|
lineToggle.checked = info.editor_line_numbers !== false;
|
||||||
previewLineToggle.checked = info.preview_line_numbers === true;
|
previewLineToggle.checked = info.preview_line_numbers === true;
|
||||||
lineLinksToggle.checked = info.line_links === 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 (["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);
|
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…");
|
setStatus(null, "Connecting…");
|
||||||
}
|
}
|
||||||
function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; }
|
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 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 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 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) {
|
function renderParticipantBadges(owners) {
|
||||||
if (!participantBadges) return;
|
if (!participantBadges) return;
|
||||||
const people = new Map();
|
const people = new Map();
|
||||||
@@ -870,15 +875,21 @@ export function startNoteEditor(adapter) {
|
|||||||
return Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : 20;
|
return Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollRatio(element) {
|
function scrollState(element) {
|
||||||
const range = Math.max(0, element.scrollHeight - element.clientHeight);
|
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() {
|
function editorScrollAnchor() {
|
||||||
|
const state = scrollState(editor);
|
||||||
return {
|
return {
|
||||||
sourceLine: 1 + Math.max(0, editor.scrollTop) / editorLineHeight(),
|
sourceLine: 1 + Math.max(0, editor.scrollTop) / editorLineHeight(),
|
||||||
ratio: scrollRatio(editor),
|
...state,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -900,8 +911,9 @@ export function startNoteEditor(adapter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function previewScrollAnchor() {
|
function previewScrollAnchor() {
|
||||||
|
const state = scrollState(preview);
|
||||||
const positions = previewLinePositions();
|
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 paddingTop = parseFloat(getComputedStyle(preview).paddingTop) || 0;
|
||||||
const viewportTop = preview.scrollTop + paddingTop;
|
const viewportTop = preview.scrollTop + paddingTop;
|
||||||
let currentIndex = positions.findIndex(position => position.bottom > viewportTop + 0.5);
|
let currentIndex = positions.findIndex(position => position.bottom > viewportTop + 0.5);
|
||||||
@@ -912,7 +924,7 @@ export function startNoteEditor(adapter) {
|
|||||||
const sourceLine = next
|
const sourceLine = next
|
||||||
? current.sourceLine + progress * (next.sourceLine - current.sourceLine)
|
? current.sourceLine + progress * (next.sourceLine - current.sourceLine)
|
||||||
: current.sourceLine;
|
: current.sourceLine;
|
||||||
return { sourceLine, ratio: scrollRatio(preview) };
|
return { sourceLine, ...state };
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeScrollAnchor(view = renderedView) {
|
function activeScrollAnchor(view = renderedView) {
|
||||||
@@ -925,7 +937,9 @@ export function startNoteEditor(adapter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scrollEditorToAnchor(anchor) {
|
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);
|
const range = Math.max(0, editor.scrollHeight - editor.clientHeight);
|
||||||
editor.scrollTop = Math.max(0, Math.min(range, (anchor.sourceLine - 1) * editorLineHeight()));
|
editor.scrollTop = Math.max(0, Math.min(range, (anchor.sourceLine - 1) * editorLineHeight()));
|
||||||
} else setScrollRatio(editor, anchor?.ratio || 0);
|
} else setScrollRatio(editor, anchor?.ratio || 0);
|
||||||
@@ -933,6 +947,14 @@ export function startNoteEditor(adapter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scrollPreviewToAnchor(anchor) {
|
function scrollPreviewToAnchor(anchor) {
|
||||||
|
if (anchor?.atEnd) {
|
||||||
|
setScrollRatio(preview, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (anchor?.atStart) {
|
||||||
|
setScrollRatio(preview, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const positions = previewLinePositions();
|
const positions = previewLinePositions();
|
||||||
if (!Number.isFinite(anchor?.sourceLine) || !positions.length) {
|
if (!Number.isFinite(anchor?.sourceLine) || !positions.length) {
|
||||||
setScrollRatio(preview, anchor?.ratio || 0);
|
setScrollRatio(preview, anchor?.ratio || 0);
|
||||||
@@ -976,7 +998,62 @@ export function startNoteEditor(adapter) {
|
|||||||
if (activeView() !== "split") return;
|
if (activeView() !== "split") return;
|
||||||
scrollPreviewToAnchor(editorScrollAnchor());
|
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);
|
const render = createRenderQueue(renderNow);
|
||||||
function queueCollaborativeOperation(operation, ownerReplacements = []) {
|
function queueCollaborativeOperation(operation, ownerReplacements = []) {
|
||||||
if (!collaboration.ready || !canEditDocument()) return false;
|
if (!collaboration.ready || !canEditDocument()) return false;
|
||||||
@@ -1066,6 +1143,16 @@ export function startNoteEditor(adapter) {
|
|||||||
return singlePaneQuery.matches ? compactView : uiState.view;
|
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 } = {}) {
|
function applyUi({ write = false, replace = false } = {}) {
|
||||||
const view = activeView();
|
const view = activeView();
|
||||||
renderedView = view;
|
renderedView = view;
|
||||||
@@ -1073,6 +1160,7 @@ export function startNoteEditor(adapter) {
|
|||||||
editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`);
|
editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`);
|
||||||
document.body.classList.toggle("compact-editor", compactToggle.checked);
|
document.body.classList.toggle("compact-editor", compactToggle.checked);
|
||||||
document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches);
|
document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches);
|
||||||
|
applyToolbarCollapsed();
|
||||||
document.querySelectorAll("[data-view]").forEach(button => {
|
document.querySelectorAll("[data-view]").forEach(button => {
|
||||||
const active = button.dataset.view === view;
|
const active = button.dataset.view === view;
|
||||||
button.classList.toggle("active", active);
|
button.classList.toggle("active", active);
|
||||||
@@ -1483,6 +1571,7 @@ export function startNoteEditor(adapter) {
|
|||||||
if (event.key === "Escape" && mobileEditorOptions?.open) mobileEditorOptions.open = false;
|
if (event.key === "Escape" && mobileEditorOptions?.open) mobileEditorOptions.open = false;
|
||||||
});
|
});
|
||||||
modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUiPreservingScroll({ write: true }); });
|
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 }); });
|
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 }); });
|
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 }); });
|
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,
|
editor_line_numbers: lineToggle.checked,
|
||||||
preview_line_numbers: previewLineToggle.checked,
|
preview_line_numbers: previewLineToggle.checked,
|
||||||
line_links: lineLinksToggle.checked,
|
line_links: lineLinksToggle.checked,
|
||||||
|
toolbar_collapsed: toolbarCollapsed,
|
||||||
font_family: fontFamily.value,
|
font_family: fontFamily.value,
|
||||||
font_size: Number(fontSize.value),
|
font_size: Number(fontSize.value),
|
||||||
};
|
};
|
||||||
@@ -1811,6 +1901,7 @@ export function startNoteEditor(adapter) {
|
|||||||
const lineIndex = Number(checkbox.dataset.sourceLine) - 1;
|
const lineIndex = Number(checkbox.dataset.sourceLine) - 1;
|
||||||
const lines = editor.value.split("\n");
|
const lines = editor.value.split("\n");
|
||||||
if (lineIndex < 0 || lineIndex >= lines.length) return;
|
if (lineIndex < 0 || lineIndex >= lines.length) return;
|
||||||
|
pendingPreviewViewport = capturePreviewViewport(checkbox);
|
||||||
lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`);
|
lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`);
|
||||||
editor.value = lines.join("\n");
|
editor.value = lines.join("\n");
|
||||||
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
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");
|
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("pl-PL");
|
||||||
}
|
}
|
||||||
|
|
||||||
function aliasCode(filename, label, mimeType) {
|
function isVideo(mimeType) {
|
||||||
const kind = String(mimeType || "").startsWith("image/") ? "image" : "file";
|
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;
|
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}]`;
|
return `[${kind}=${filename},${safeLabel}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,12 +79,84 @@ function safeAttachmentUrl(value) {
|
|||||||
: safePublicUrl(raw, { allowMailto: false });
|
: 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 = () => { } }) {
|
export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxSize = () => 0, canDelete, canUpload, canEdit = () => true, toast, onFilesChanged = () => { } }) {
|
||||||
const dialog = document.querySelector("#files-dialog");
|
const dialog = document.querySelector("#files-dialog");
|
||||||
const list = document.querySelector("#files-list");
|
const list = document.querySelector("#files-list");
|
||||||
const input = document.querySelector("#file-input");
|
const input = document.querySelector("#file-input");
|
||||||
const footer = document.querySelector("#footer-files");
|
const footer = document.querySelector("#footer-files");
|
||||||
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
|
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 } = {}) {
|
async function loadFiles({ open = false } = {}) {
|
||||||
try {
|
try {
|
||||||
@@ -88,13 +169,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
|||||||
<div class="file-name">${escapeHtml(file.filename)}</div>
|
<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 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>
|
||||||
<div class="file-actions">
|
<div class="file-actions">${fileActionButtons(file)}</div>
|
||||||
<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-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 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>';
|
</div>`).join("") : '<p class="dialog-copy">No files uploaded.</p>';
|
||||||
onFilesChanged(files);
|
onFilesChanged(files);
|
||||||
@@ -112,7 +187,15 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
|||||||
return { start: start + text.length, end: start + text.length };
|
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);
|
const uploadToast = createUploadToast(file.name);
|
||||||
let completed = false;
|
let completed = false;
|
||||||
|
|
||||||
@@ -131,7 +214,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
|||||||
});
|
});
|
||||||
if (completed) return;
|
if (completed) return;
|
||||||
completed = true;
|
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);
|
await onUploaded(text);
|
||||||
uploadToast.success();
|
uploadToast.success();
|
||||||
await loadFiles();
|
await loadFiles();
|
||||||
@@ -158,20 +241,21 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
|||||||
input.addEventListener("change", async event => {
|
input.addEventListener("change", async event => {
|
||||||
let file = event.target.files[0];
|
let file = event.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
input.value = "";
|
||||||
if (file.type.startsWith("image/")) {
|
if (file.type.startsWith("image/")) {
|
||||||
try {
|
try {
|
||||||
file = await prepareImageFile(file);
|
file = await prepareImageFile(file);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(error.message);
|
toast(error.message);
|
||||||
input.value = "";
|
|
||||||
return;
|
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 };
|
const range = { start: editor.selectionStart, end: editor.selectionEnd };
|
||||||
input.value = "";
|
await uploadFile(file, text => insertMode === "player" ? insertVideoPlayer(text, range) : insertText(text, range), insertMode);
|
||||||
await uploadFile(file, text => insertText(text, range));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector("#editor-workspace")?.addEventListener("paste", async event => {
|
document.querySelector("#editor-workspace")?.addEventListener("paste", async event => {
|
||||||
@@ -202,7 +286,10 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
|||||||
state.replaceEnd = state.cursor;
|
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);
|
editor.setSelectionRange(state.cursor, state.cursor);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,11 +299,14 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
|||||||
list.addEventListener("click", async event => {
|
list.addEventListener("click", async event => {
|
||||||
const addButton = event.target.closest("[data-add-file-to-note]");
|
const addButton = event.target.closest("[data-add-file-to-note]");
|
||||||
if (addButton) {
|
if (addButton) {
|
||||||
const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime);
|
const mode = addButton.dataset.insertMode;
|
||||||
insertText(text);
|
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");
|
toast("Added to note");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showButton = event.target.closest("[data-show-file-code]");
|
const showButton = event.target.closest("[data-show-file-code]");
|
||||||
if (showButton) {
|
if (showButton) {
|
||||||
const panel = showButton.closest(".file-row").querySelector(".file-code");
|
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);
|
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime);
|
||||||
} else if (showButton.dataset.showFileCode === "markdown") {
|
} else if (showButton.dataset.showFileCode === "markdown") {
|
||||||
text = markdownCode(safeUrl, showButton.dataset.name, showButton.dataset.mime);
|
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]");
|
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]");
|
const deleteButton = event.target.closest("[data-delete-file]");
|
||||||
if (!deleteButton) return;
|
if (!deleteButton) return;
|
||||||
if (!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`, { title: "Delete file", confirmText: "Delete", danger: true })) 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 }) });
|
await api(endpoints.remove(deleteButton.dataset.deleteFile), { method: "DELETE", headers: {}, body: JSON.stringify({ access_token: getAccessToken() || null }) });
|
||||||
toast("File deleted");
|
toast("File deleted");
|
||||||
await loadFiles();
|
await loadFiles();
|
||||||
} catch (error) { toast(error.message); }
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { loadFiles };
|
return { loadFiles };
|
||||||
|
|||||||
+5
-3
@@ -15,6 +15,7 @@ import { copyText } from "@rustpad/clipboard";
|
|||||||
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles } from "@rustpad/markdown";
|
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles } from "@rustpad/markdown";
|
||||||
import { toast } from "@rustpad/toast";
|
import { toast } from "@rustpad/toast";
|
||||||
import { getTheme } from "@rustpad/theme";
|
import { getTheme } from "@rustpad/theme";
|
||||||
|
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
|
||||||
|
|
||||||
const token = location.pathname.split("/").filter(Boolean)[1];
|
const token = location.pathname.split("/").filter(Boolean)[1];
|
||||||
const content = document.querySelector("#public-content");
|
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");
|
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 = "";
|
let pagePassword = "";
|
||||||
function pageHeaders() { return pagePassword ? { "X-RustPad-Page-Password": 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 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 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 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) {
|
function lockPublicContent(allowTaskUpdates) {
|
||||||
content.querySelectorAll('[contenteditable]').forEach(node => node.removeAttribute('contenteditable'));
|
content.querySelectorAll('[contenteditable]').forEach(node => node.removeAttribute('contenteditable'));
|
||||||
content.querySelectorAll('.preview-editable').forEach(node => node.classList.remove('preview-editable'));
|
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" });
|
target.scrollIntoView({ behavior, block: "start" });
|
||||||
return true;
|
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 => {
|
content.addEventListener("click", event => {
|
||||||
const link = event.target.closest('.markdown-toc a[href^="#"]');
|
const link = event.target.closest('.markdown-toc a[href^="#"]');
|
||||||
if (!link) return;
|
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