Compare commits
14
Commits
ba0c2a84d5
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6329ac33d1 | ||
|
|
099840d66a | ||
|
|
490716d876 | ||
|
|
f0adfffe38 | ||
|
|
de3cb9f111 | ||
|
|
e9f1dc515c | ||
|
|
0a269cf6fb | ||
|
|
a6efa52f78 | ||
|
|
6d0b9a225f | ||
|
|
eede4e88e6 | ||
|
|
86a6fa1f2f | ||
|
|
295761123f | ||
|
|
f16a860f0a | ||
|
|
8df9b5d092 |
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.72"
|
version = "0.2.83"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.72"
|
version = "0.2.83"
|
||||||
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"
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ Optional databases in Docker Compose:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
docker compose --profile postgres up -d postgres
|
docker compose --profile postgres up -d pgsql
|
||||||
DATABASE_URL=postgres://rustpad:rustpad@postgres:5432/rustpad docker compose up -d rustpad
|
DATABASE_URL=postgres://rustpad:rustpad@postgres:5432/rustpad docker compose up -d rustpad
|
||||||
|
|
||||||
# MySQL
|
# MySQL
|
||||||
|
|||||||
+18
-1
@@ -345,6 +345,7 @@
|
|||||||
"editor.compactView": "Compact view",
|
"editor.compactView": "Compact view",
|
||||||
"editor.connectionError": "Editor connection error",
|
"editor.connectionError": "Editor connection error",
|
||||||
"editor.connectionInterrupted": "Connection interrupted",
|
"editor.connectionInterrupted": "Connection interrupted",
|
||||||
|
"editor.connectionPersistent": "The live connection is still unavailable. Automatic reconnect continues.",
|
||||||
"editor.copyOpen": "Copy its link and open it in a new tab",
|
"editor.copyOpen": "Copy its link and open it in a new tab",
|
||||||
"editor.copyThisLink": "Copy this link",
|
"editor.copyThisLink": "Copy this link",
|
||||||
"editor.currentLocation": "Current location",
|
"editor.currentLocation": "Current location",
|
||||||
@@ -359,6 +360,8 @@
|
|||||||
"editor.emoji": "😀 Emoji",
|
"editor.emoji": "😀 Emoji",
|
||||||
"editor.emojiCategories": "Emoji categories",
|
"editor.emojiCategories": "Emoji categories",
|
||||||
"editor.emojiEmpty": "No emoji found.",
|
"editor.emojiEmpty": "No emoji found.",
|
||||||
|
"editor.emojiLoading": "Loading emoji…",
|
||||||
|
"editor.emojiLoadFailed": "Could not load emoji. Close and reopen the picker to retry.",
|
||||||
"editor.emojiLabel": "Emoji",
|
"editor.emojiLabel": "Emoji",
|
||||||
"editor.emojiSearch": "Search emoji",
|
"editor.emojiSearch": "Search emoji",
|
||||||
"editor.emojiSearchPlaceholder": "Search emoji…",
|
"editor.emojiSearchPlaceholder": "Search emoji…",
|
||||||
@@ -599,6 +602,12 @@
|
|||||||
"files.download": "Download {label}",
|
"files.download": "Download {label}",
|
||||||
"files.downloadCode": "Download code",
|
"files.downloadCode": "Download code",
|
||||||
"files.empty": "No files uploaded.",
|
"files.empty": "No files uploaded.",
|
||||||
|
"files.filter": "Filter files",
|
||||||
|
"files.filterPlaceholder": "Filter files…",
|
||||||
|
"files.loading": "Loading files…",
|
||||||
|
"files.refreshing": "Refreshing…",
|
||||||
|
"files.noMatches": "No files match this filter.",
|
||||||
|
"files.retry": "Try again",
|
||||||
"files.generatedCode": "Generated file code",
|
"files.generatedCode": "Generated file code",
|
||||||
"files.imageAlignment": "Image alignment",
|
"files.imageAlignment": "Image alignment",
|
||||||
"files.imageHeight": "Image height",
|
"files.imageHeight": "Image height",
|
||||||
@@ -835,7 +844,9 @@
|
|||||||
"status.accessFull": "Access: full",
|
"status.accessFull": "Access: full",
|
||||||
"status.accessReadOnly": "Access: read only",
|
"status.accessReadOnly": "Access: read only",
|
||||||
"status.applicationError": "Application error",
|
"status.applicationError": "Application error",
|
||||||
|
"status.connected": "Connected",
|
||||||
"status.connecting": "Connecting…",
|
"status.connecting": "Connecting…",
|
||||||
|
"status.connectionError": "Connection error",
|
||||||
"status.heartbeatTimeout": "heartbeat timeout",
|
"status.heartbeatTimeout": "heartbeat timeout",
|
||||||
"status.inactiveTimeout": "The connection stopped responding after the tab was inactive.",
|
"status.inactiveTimeout": "The connection stopped responding after the tab was inactive.",
|
||||||
"status.measuring": "Measuring",
|
"status.measuring": "Measuring",
|
||||||
@@ -954,6 +965,12 @@
|
|||||||
"error.document.publishedNotFound": "Published page not found · RustPad",
|
"error.document.publishedNotFound": "Published page not found · RustPad",
|
||||||
"error.document.pageNotFound": "Page not found · RustPad",
|
"error.document.pageNotFound": "Page not found · RustPad",
|
||||||
"error.document.methodNotAllowed": "Method not allowed · RustPad",
|
"error.document.methodNotAllowed": "Method not allowed · RustPad",
|
||||||
"error.document.serverError": "Server error · RustPad"
|
"error.document.serverError": "Server error · RustPad",
|
||||||
|
"files.emptyHelp": "Upload a file to keep it with this note and insert it into the document.",
|
||||||
|
"files.filteredSummary": "{shown} of {count} files · {size}",
|
||||||
|
"files.less": "Less",
|
||||||
|
"files.noMatchesHelp": "Try a different file name or type.",
|
||||||
|
"files.link": "Link",
|
||||||
|
"files.markdown": "Markdown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-1
@@ -345,6 +345,7 @@
|
|||||||
"editor.compactView": "Widok kompaktowy",
|
"editor.compactView": "Widok kompaktowy",
|
||||||
"editor.connectionError": "Błąd połączenia edytora",
|
"editor.connectionError": "Błąd połączenia edytora",
|
||||||
"editor.connectionInterrupted": "Połączenie przerwane",
|
"editor.connectionInterrupted": "Połączenie przerwane",
|
||||||
|
"editor.connectionPersistent": "Połączenie na żywo nadal jest niedostępne. Automatyczne ponowne łączenie trwa.",
|
||||||
"editor.copyOpen": "Skopiuj link i otwórz go w nowej karcie",
|
"editor.copyOpen": "Skopiuj link i otwórz go w nowej karcie",
|
||||||
"editor.copyThisLink": "Kopiuj ten link",
|
"editor.copyThisLink": "Kopiuj ten link",
|
||||||
"editor.currentLocation": "Bieżąca lokalizacja",
|
"editor.currentLocation": "Bieżąca lokalizacja",
|
||||||
@@ -359,6 +360,8 @@
|
|||||||
"editor.emoji": "😀 Emoji",
|
"editor.emoji": "😀 Emoji",
|
||||||
"editor.emojiCategories": "Kategorie emoji",
|
"editor.emojiCategories": "Kategorie emoji",
|
||||||
"editor.emojiEmpty": "Nie znaleziono emoji.",
|
"editor.emojiEmpty": "Nie znaleziono emoji.",
|
||||||
|
"editor.emojiLoading": "Ładowanie emoji…",
|
||||||
|
"editor.emojiLoadFailed": "Nie udało się wczytać emoji. Zamknij i otwórz ponownie wybór emoji, aby spróbować jeszcze raz.",
|
||||||
"editor.emojiLabel": "Emoji",
|
"editor.emojiLabel": "Emoji",
|
||||||
"editor.emojiSearch": "Szukaj emoji",
|
"editor.emojiSearch": "Szukaj emoji",
|
||||||
"editor.emojiSearchPlaceholder": "Szukaj emoji…",
|
"editor.emojiSearchPlaceholder": "Szukaj emoji…",
|
||||||
@@ -599,6 +602,12 @@
|
|||||||
"files.download": "Pobierz {label}",
|
"files.download": "Pobierz {label}",
|
||||||
"files.downloadCode": "Kod pobierania",
|
"files.downloadCode": "Kod pobierania",
|
||||||
"files.empty": "Nie przesłano żadnych plików.",
|
"files.empty": "Nie przesłano żadnych plików.",
|
||||||
|
"files.filter": "Filtruj pliki",
|
||||||
|
"files.filterPlaceholder": "Filtruj pliki…",
|
||||||
|
"files.loading": "Ładowanie plików…",
|
||||||
|
"files.refreshing": "Odświeżanie…",
|
||||||
|
"files.noMatches": "Żaden plik nie pasuje do filtra.",
|
||||||
|
"files.retry": "Spróbuj ponownie",
|
||||||
"files.generatedCode": "Wygenerowany kod pliku",
|
"files.generatedCode": "Wygenerowany kod pliku",
|
||||||
"files.imageAlignment": "Wyrównanie obrazu",
|
"files.imageAlignment": "Wyrównanie obrazu",
|
||||||
"files.imageHeight": "Wysokość obrazu",
|
"files.imageHeight": "Wysokość obrazu",
|
||||||
@@ -835,7 +844,9 @@
|
|||||||
"status.accessFull": "Dostęp: pełny",
|
"status.accessFull": "Dostęp: pełny",
|
||||||
"status.accessReadOnly": "Dostęp: tylko odczyt",
|
"status.accessReadOnly": "Dostęp: tylko odczyt",
|
||||||
"status.applicationError": "Błąd aplikacji",
|
"status.applicationError": "Błąd aplikacji",
|
||||||
|
"status.connected": "Połączono",
|
||||||
"status.connecting": "Łączenie…",
|
"status.connecting": "Łączenie…",
|
||||||
|
"status.connectionError": "Błąd połączenia",
|
||||||
"status.heartbeatTimeout": "przekroczono czas oczekiwania na heartbeat",
|
"status.heartbeatTimeout": "przekroczono czas oczekiwania na heartbeat",
|
||||||
"status.inactiveTimeout": "Połączenie przestało odpowiadać po okresie nieaktywności karty.",
|
"status.inactiveTimeout": "Połączenie przestało odpowiadać po okresie nieaktywności karty.",
|
||||||
"status.measuring": "Pomiar",
|
"status.measuring": "Pomiar",
|
||||||
@@ -954,6 +965,12 @@
|
|||||||
"error.document.publishedNotFound": "Nie znaleziono opublikowanej strony · RustPad",
|
"error.document.publishedNotFound": "Nie znaleziono opublikowanej strony · RustPad",
|
||||||
"error.document.pageNotFound": "Nie znaleziono strony · RustPad",
|
"error.document.pageNotFound": "Nie znaleziono strony · RustPad",
|
||||||
"error.document.methodNotAllowed": "Metoda niedozwolona · RustPad",
|
"error.document.methodNotAllowed": "Metoda niedozwolona · RustPad",
|
||||||
"error.document.serverError": "Błąd serwera · RustPad"
|
"error.document.serverError": "Błąd serwera · RustPad",
|
||||||
|
"files.emptyHelp": "Prześlij plik, aby zachować go przy tej notatce i wstawiać do dokumentu.",
|
||||||
|
"files.filteredSummary": "{shown} z {count} plików · {size}",
|
||||||
|
"files.less": "Mniej",
|
||||||
|
"files.noMatchesHelp": "Spróbuj innej nazwy pliku lub typu.",
|
||||||
|
"files.link": "Link",
|
||||||
|
"files.markdown": "Markdown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="/static/css/styles.css">
|
||||||
|
<link rel="stylesheet" href="/static/libs/rustpad-player/player.css">
|
||||||
|
<script>window.__RUSTPAD_CONFIG__={assetVersion:"test"};</script>
|
||||||
|
<script type="importmap">
|
||||||
|
{"imports":{
|
||||||
|
"@rustpad/i18n":"/static/js/i18n.js",
|
||||||
|
"@rustpad/toast":"/static/js/toast.js",
|
||||||
|
"@rustpad/vendor-libs":"/static/js/vendor-libs.js",
|
||||||
|
"@rustpad/markdown":"/static/js/markdown.js",
|
||||||
|
"@rustpad/emoji-data":"/static/js/emoji-data.js",
|
||||||
|
"@rustpad/image-alias":"/static/js/image-alias.js"
|
||||||
|
}}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="toast"></div>
|
||||||
|
<button id="pdf">PDF</button>
|
||||||
|
<dialog id="dlg">
|
||||||
|
<form id="form" method="dialog">
|
||||||
|
<select id="pdf-page-format"><option value="screen-16-9" selected>screen</option></select>
|
||||||
|
<select id="pdf-page-margin"><option value="12" selected>12</option></select>
|
||||||
|
<input id="pdf-include-title" type="checkbox">
|
||||||
|
<input id="pdf-preserve-colors" type="checkbox">
|
||||||
|
<button data-pdf-cancel type="button">cancel</button>
|
||||||
|
<button type="submit">go</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
<div id="preview" class="preview markdown-body"></div>
|
||||||
|
<script type="module">
|
||||||
|
import { renderMarkdown } from "@rustpad/markdown";
|
||||||
|
import { bindPdfExport } from "/static/js/pdf-export.js";
|
||||||
|
|
||||||
|
const source = await (await fetch('/TEST_NOTE_FEATURES.txt')).text();
|
||||||
|
const preview = document.querySelector('#preview');
|
||||||
|
preview.innerHTML = renderMarkdown(source);
|
||||||
|
|
||||||
|
// Replace raw Mermaid source blocks with representative SVGs so pagination
|
||||||
|
// exercises the same tall/wide atomic visuals as the real rendered preview.
|
||||||
|
[...preview.querySelectorAll('.mermaid')].forEach((node, index) => {
|
||||||
|
const tall = index === 2 || index === 3 || index === 4;
|
||||||
|
const height = tall ? 1500 : 520;
|
||||||
|
node.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 ${height}" width="900" height="${height}"><rect width="900" height="${height}" fill="#efeae2"/><rect x="90" y="60" width="720" height="180" rx="16" fill="#202323"/><text x="450" y="160" text-anchor="middle" font-size="56" fill="white">Mermaid ${index + 1}</text><line x1="450" y1="240" x2="450" y2="${height - 100}" stroke="#777" stroke-width="6"/></svg>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
let popupFrame = null;
|
||||||
|
window.open = () => {
|
||||||
|
popupFrame = document.createElement('iframe');
|
||||||
|
popupFrame.id = 'print-frame';
|
||||||
|
popupFrame.style.cssText = 'position:absolute;left:-10000px;top:0;width:1400px;height:900px;border:0';
|
||||||
|
document.body.appendChild(popupFrame);
|
||||||
|
const win = popupFrame.contentWindow;
|
||||||
|
win.focus = () => {};
|
||||||
|
win.print = async () => {
|
||||||
|
const html = win.document.documentElement.outerHTML;
|
||||||
|
await fetch('/capture', { method: 'POST', headers: {'Content-Type':'text/html'}, body: html });
|
||||||
|
document.documentElement.dataset.captureDone = '1';
|
||||||
|
};
|
||||||
|
return win;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialog = document.querySelector('#dlg');
|
||||||
|
bindPdfExport({
|
||||||
|
button: document.querySelector('#pdf'),
|
||||||
|
dialog,
|
||||||
|
form: document.querySelector('#form'),
|
||||||
|
preview,
|
||||||
|
title: () => 'TEST_NOTE_FEATURES',
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.showModal();
|
||||||
|
document.querySelector('#form').dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because one or more lines are too long
Executable
+350
@@ -0,0 +1,350 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Usage:
|
||||||
|
bash scripts/load-speed.sh 'https://rustpad.example.com/p/test-note-features'
|
||||||
|
bash scripts/load-speed.sh 'https://rustpad.example.com/w/team'
|
||||||
|
bash scripts/load-speed.sh 'https://rustpad.example.com/w/team/n/note'
|
||||||
|
|
||||||
|
The script will prompt for the RustPad account login and password.
|
||||||
|
If the pad/workspace also has resource password protection, it will ask for that password separately.
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
for cmd in curl jq wc mktemp; do
|
||||||
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: required command not found: $cmd" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $# -ne 1 ]]; then
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
INPUT_URL="$1"
|
||||||
|
|
||||||
|
if [[ ! "$INPUT_URL" =~ ^(https?://[^/]+)(/.*)?$ ]]; then
|
||||||
|
echo "ERROR: pass a full URL starting with http:// or https://" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASE="${BASH_REMATCH[1]}"
|
||||||
|
PATH_WITH_QUERY="${BASH_REMATCH[2]:-/}"
|
||||||
|
PATH_ONLY="${PATH_WITH_QUERY%%\#*}"
|
||||||
|
PATH_ONLY="${PATH_ONLY%%\?*}"
|
||||||
|
[[ "$PATH_ONLY" != "/" ]] && PATH_ONLY="${PATH_ONLY%/}"
|
||||||
|
|
||||||
|
TYPE=""
|
||||||
|
RESOURCE_KIND=""
|
||||||
|
RESOURCE_SLUG=""
|
||||||
|
API_BASE=""
|
||||||
|
WORKSPACE_SLUG=""
|
||||||
|
NOTE_SLUG=""
|
||||||
|
PAD_SLUG=""
|
||||||
|
|
||||||
|
if [[ "$PATH_ONLY" =~ ^/p/([^/]+)$ ]]; then
|
||||||
|
TYPE="pad"
|
||||||
|
RESOURCE_KIND="pad"
|
||||||
|
PAD_SLUG="${BASH_REMATCH[1]}"
|
||||||
|
RESOURCE_SLUG="$PAD_SLUG"
|
||||||
|
API_BASE="$BASE/api/pads/$PAD_SLUG"
|
||||||
|
elif [[ "$PATH_ONLY" =~ ^/w/([^/]+)/n/([^/]+)$ ]]; then
|
||||||
|
TYPE="workspace-note"
|
||||||
|
RESOURCE_KIND="workspace"
|
||||||
|
WORKSPACE_SLUG="${BASH_REMATCH[1]}"
|
||||||
|
NOTE_SLUG="${BASH_REMATCH[2]}"
|
||||||
|
RESOURCE_SLUG="$WORKSPACE_SLUG"
|
||||||
|
API_BASE="$BASE/api/workspaces/$WORKSPACE_SLUG/notes/$NOTE_SLUG"
|
||||||
|
elif [[ "$PATH_ONLY" =~ ^/w/([^/]+)$ ]]; then
|
||||||
|
TYPE="workspace"
|
||||||
|
RESOURCE_KIND="workspace"
|
||||||
|
WORKSPACE_SLUG="${BASH_REMATCH[1]}"
|
||||||
|
RESOURCE_SLUG="$WORKSPACE_SLUG"
|
||||||
|
API_BASE="$BASE/api/workspaces/$WORKSPACE_SLUG"
|
||||||
|
else
|
||||||
|
echo "ERROR: unsupported RustPad URL: $PATH_ONLY" >&2
|
||||||
|
echo "Supported: /p/<pad>, /w/<workspace>, /w/<workspace>/n/<note>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
COOKIE_JAR="$TMP_DIR/cookies.txt"
|
||||||
|
CSRF_BODY="$TMP_DIR/csrf.json"
|
||||||
|
LOGIN_BODY="$TMP_DIR/login.json"
|
||||||
|
INFO_BODY="$TMP_DIR/info.json"
|
||||||
|
UNLOCK_BODY="$TMP_DIR/unlock.json"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TMP_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
touch "$COOKIE_JAR"
|
||||||
|
chmod 600 "$COOKIE_JAR"
|
||||||
|
|
||||||
|
CURL_COMMON=(
|
||||||
|
--silent
|
||||||
|
--show-error
|
||||||
|
--compressed
|
||||||
|
--connect-timeout 10
|
||||||
|
--max-time 120
|
||||||
|
-b "$COOKIE_JAR"
|
||||||
|
-c "$COOKIE_JAR"
|
||||||
|
)
|
||||||
|
|
||||||
|
human_bytes() {
|
||||||
|
local bytes="$1"
|
||||||
|
if (( bytes >= 1048576 )); then
|
||||||
|
awk -v b="$bytes" 'BEGIN { printf "%.2f MiB", b / 1048576 }'
|
||||||
|
elif (( bytes >= 1024 )); then
|
||||||
|
awk -v b="$bytes" 'BEGIN { printf "%.1f KiB", b / 1024 }'
|
||||||
|
else
|
||||||
|
printf '%s B' "$bytes"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
measure_to_file() {
|
||||||
|
local name="$1"
|
||||||
|
local outfile="$2"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
local stats code transfer ttfb total body_bytes
|
||||||
|
stats="$(curl "${CURL_COMMON[@]}" "$@" \
|
||||||
|
-o "$outfile" \
|
||||||
|
-w '%{http_code}|%{size_download}|%{time_starttransfer}|%{time_total}')"
|
||||||
|
|
||||||
|
IFS='|' read -r code transfer ttfb total <<< "$stats"
|
||||||
|
body_bytes="$(wc -c < "$outfile" | tr -d '[:space:]')"
|
||||||
|
|
||||||
|
printf '%-20s HTTP=%-3s transfer=%10s body=%10s TTFB=%8ss total=%8ss\n' \
|
||||||
|
"$name" "$code" "$(human_bytes "${transfer%.*}")" "$(human_bytes "$body_bytes")" "$ttfb" "$total"
|
||||||
|
|
||||||
|
LAST_HTTP="$code"
|
||||||
|
}
|
||||||
|
|
||||||
|
measure() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
local outfile="$TMP_DIR/measure-${name//[^a-zA-Z0-9_-]/_}.body"
|
||||||
|
measure_to_file "$name" "$outfile" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
api_post_json() {
|
||||||
|
local url="$1"
|
||||||
|
local json="$2"
|
||||||
|
local outfile="$3"
|
||||||
|
local code
|
||||||
|
|
||||||
|
code="$(curl "${CURL_COMMON[@]}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "x-rustpad-csrf: $CSRF" \
|
||||||
|
--data "$json" \
|
||||||
|
-o "$outfile" \
|
||||||
|
-w '%{http_code}' \
|
||||||
|
"$url")"
|
||||||
|
LAST_HTTP="$code"
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_csrf() {
|
||||||
|
local code
|
||||||
|
code="$(curl "${CURL_COMMON[@]}" \
|
||||||
|
-o "$CSRF_BODY" \
|
||||||
|
-w '%{http_code}' \
|
||||||
|
"$BASE/api/security/csrf")"
|
||||||
|
|
||||||
|
if [[ "$code" != "200" ]]; then
|
||||||
|
echo "ERROR: CSRF endpoint returned HTTP $code" >&2
|
||||||
|
cat "$CSRF_BODY" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CSRF="$(jq -r '.token // empty' "$CSRF_BODY")"
|
||||||
|
if [[ -z "$CSRF" ]]; then
|
||||||
|
echo "ERROR: server did not return a CSRF token" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
login_account() {
|
||||||
|
local login password payload code message nickname
|
||||||
|
|
||||||
|
printf 'Login/e-mail: '
|
||||||
|
IFS= read -r login
|
||||||
|
if [[ -z "$login" ]]; then
|
||||||
|
echo "ERROR: login cannot be empty" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Password: '
|
||||||
|
IFS= read -rs password
|
||||||
|
printf '\n'
|
||||||
|
if [[ -z "$password" ]]; then
|
||||||
|
echo "ERROR: password cannot be empty" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
payload="$(jq -nc --arg email "$login" --arg password "$password" '{email:$email,password:$password}')"
|
||||||
|
unset password
|
||||||
|
|
||||||
|
code="$(curl "${CURL_COMMON[@]}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "x-rustpad-csrf: $CSRF" \
|
||||||
|
--data "$payload" \
|
||||||
|
-o "$LOGIN_BODY" \
|
||||||
|
-w '%{http_code}' \
|
||||||
|
"$BASE/api/auth/login")"
|
||||||
|
unset payload
|
||||||
|
|
||||||
|
if [[ "$code" != "200" ]]; then
|
||||||
|
message="$(jq -r '.error // empty' "$LOGIN_BODY" 2>/dev/null || true)"
|
||||||
|
echo "ERROR: login failed (HTTP $code)${message:+: $message}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
nickname="$(jq -r '.nickname // empty' "$LOGIN_BODY")"
|
||||||
|
echo "Logged in${nickname:+ as $nickname}."
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock_resource_if_needed() {
|
||||||
|
local info_file="$1"
|
||||||
|
local protected access_level resource_password payload code message
|
||||||
|
|
||||||
|
protected="$(jq -r '.protected // false' "$info_file" 2>/dev/null || echo false)"
|
||||||
|
access_level="$(jq -r '.access_level // empty' "$info_file" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [[ "$protected" != "true" || "$access_level" != "none" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Resource password (%s): ' "$RESOURCE_KIND"
|
||||||
|
IFS= read -rs resource_password
|
||||||
|
printf '\n'
|
||||||
|
if [[ -z "$resource_password" ]]; then
|
||||||
|
echo "ERROR: this resource requires a password" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
payload="$(jq -nc \
|
||||||
|
--arg kind "$RESOURCE_KIND" \
|
||||||
|
--arg slug "$RESOURCE_SLUG" \
|
||||||
|
--arg password "$resource_password" \
|
||||||
|
'{kind:$kind,slug:$slug,password:$password}')"
|
||||||
|
unset resource_password
|
||||||
|
|
||||||
|
api_post_json "$BASE/api/access-token" "$payload" "$UNLOCK_BODY"
|
||||||
|
code="$LAST_HTTP"
|
||||||
|
unset payload
|
||||||
|
|
||||||
|
if [[ "$code" != "200" ]]; then
|
||||||
|
message="$(jq -r '.error // empty' "$UNLOCK_BODY" 2>/dev/null || true)"
|
||||||
|
echo "ERROR: resource unlock failed (HTTP $code)${message:+: $message}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Resource unlocked."
|
||||||
|
}
|
||||||
|
|
||||||
|
print_header() {
|
||||||
|
echo
|
||||||
|
echo "Target: $INPUT_URL"
|
||||||
|
echo "Type: $TYPE"
|
||||||
|
echo "Base: $BASE"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_csrf
|
||||||
|
login_account
|
||||||
|
print_header
|
||||||
|
|
||||||
|
echo '=== PAGE / SESSION ==='
|
||||||
|
measure "page_html" "$BASE$PATH_WITH_QUERY"
|
||||||
|
measure "auth_me" "$BASE/api/auth/me"
|
||||||
|
|
||||||
|
echo
|
||||||
|
case "$TYPE" in
|
||||||
|
pad)
|
||||||
|
echo '=== INITIAL NOTE LOAD ==='
|
||||||
|
measure_to_file "note_info" "$INFO_BODY" "$API_BASE"
|
||||||
|
if [[ "$LAST_HTTP" != "200" ]]; then
|
||||||
|
echo "ERROR: note_info failed; cannot continue reliably." >&2
|
||||||
|
jq -r '.error // empty' "$INFO_BODY" 2>/dev/null >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
unlock_resource_if_needed "$INFO_BODY"
|
||||||
|
|
||||||
|
measure "editor_color" "$API_BASE/editor-color"
|
||||||
|
measure "favorite_status" "$BASE/api/auth/favorites/status?kind=pad&slug=$PAD_SLUG"
|
||||||
|
measure "files" \
|
||||||
|
-X PUT \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "x-rustpad-csrf: $CSRF" \
|
||||||
|
--data '{"access_token":null}' \
|
||||||
|
"$API_BASE/files"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo '=== ON DEMAND ==='
|
||||||
|
measure "history" \
|
||||||
|
-X POST \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "x-rustpad-csrf: $CSRF" \
|
||||||
|
--data '{"access_token":null}' \
|
||||||
|
"$API_BASE/history"
|
||||||
|
;;
|
||||||
|
|
||||||
|
workspace-note)
|
||||||
|
echo '=== INITIAL NOTE LOAD ==='
|
||||||
|
measure_to_file "note_info" "$INFO_BODY" "$API_BASE"
|
||||||
|
if [[ "$LAST_HTTP" != "200" ]]; then
|
||||||
|
echo "ERROR: note_info failed; cannot continue reliably." >&2
|
||||||
|
jq -r '.error // empty' "$INFO_BODY" 2>/dev/null >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
unlock_resource_if_needed "$INFO_BODY"
|
||||||
|
|
||||||
|
measure "editor_color" "$API_BASE/editor-color"
|
||||||
|
measure "favorite_status" "$BASE/api/auth/favorites/status?kind=note&slug=$NOTE_SLUG&workspace_slug=$WORKSPACE_SLUG"
|
||||||
|
measure "files" \
|
||||||
|
-X PUT \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "x-rustpad-csrf: $CSRF" \
|
||||||
|
--data '{"access_token":null}' \
|
||||||
|
"$API_BASE/files"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo '=== ON DEMAND ==='
|
||||||
|
measure "history" \
|
||||||
|
-X POST \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "x-rustpad-csrf: $CSRF" \
|
||||||
|
--data '{"access_token":null}' \
|
||||||
|
"$API_BASE/history"
|
||||||
|
;;
|
||||||
|
|
||||||
|
workspace)
|
||||||
|
echo '=== INITIAL WORKSPACE LOAD ==='
|
||||||
|
measure_to_file "workspace_info" "$INFO_BODY" "$API_BASE"
|
||||||
|
if [[ "$LAST_HTTP" != "200" ]]; then
|
||||||
|
echo "ERROR: workspace_info failed; cannot continue reliably." >&2
|
||||||
|
jq -r '.error // empty' "$INFO_BODY" 2>/dev/null >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
unlock_resource_if_needed "$INFO_BODY"
|
||||||
|
|
||||||
|
measure "workspace_open" \
|
||||||
|
-X POST \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "x-rustpad-csrf: $CSRF" \
|
||||||
|
--data '{"access_token":null}' \
|
||||||
|
"$API_BASE/open?q=&page=1&per_page=25"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo 'Note: WebSocket payloads are not included in these curl measurements.'
|
||||||
@@ -19,9 +19,12 @@ const MODULES: &[&str] = &[
|
|||||||
"clipboard",
|
"clipboard",
|
||||||
"collaboration",
|
"collaboration",
|
||||||
"collaboration-session",
|
"collaboration-session",
|
||||||
|
"connection-state",
|
||||||
"editor-format",
|
"editor-format",
|
||||||
"emoji-data",
|
"emoji-data",
|
||||||
|
"emoji-groups",
|
||||||
"emoji-picker",
|
"emoji-picker",
|
||||||
|
"emoji-shortcodes",
|
||||||
"image-alias",
|
"image-alias",
|
||||||
"image-upload",
|
"image-upload",
|
||||||
"i18n",
|
"i18n",
|
||||||
|
|||||||
+370
-119
@@ -695,6 +695,12 @@ textarea:focus {
|
|||||||
background: var(--success);
|
background: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status__dot.is-connecting,
|
||||||
|
.status__dot.is-reconnecting {
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status__dot.is-error,
|
||||||
.status__dot.is-offline {
|
.status__dot.is-offline {
|
||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
}
|
}
|
||||||
@@ -710,9 +716,9 @@ textarea:focus {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 11px 14px;
|
padding: 11px 14px;
|
||||||
border: 1px solid color-mix(in srgb, var(--danger) 48%, var(--border-strong));
|
border: 1px solid color-mix(in srgb, var(--warning) 48%, var(--border-strong));
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: color-mix(in srgb, var(--surface-strong, var(--surface-strong)) 94%, var(--danger));
|
background: color-mix(in srgb, var(--surface-strong, var(--surface-strong)) 94%, var(--warning));
|
||||||
box-shadow: 0 16px 42px var(--shadow-38);
|
box-shadow: 0 16px 42px var(--shadow-38);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -725,6 +731,11 @@ textarea:focus {
|
|||||||
transform: translate(-50%, 0) scale(1);
|
transform: translate(-50%, 0) scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connection-notice.is-error {
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 58%, var(--border-strong));
|
||||||
|
background: color-mix(in srgb, var(--surface-strong, var(--surface-strong)) 94%, var(--danger));
|
||||||
|
}
|
||||||
|
|
||||||
.connection-notice.is-restored {
|
.connection-notice.is-restored {
|
||||||
border-color: color-mix(in srgb, var(--success) 58%, var(--border-strong));
|
border-color: color-mix(in srgb, var(--success) 58%, var(--border-strong));
|
||||||
background: color-mix(in srgb, var(--surface-strong, var(--surface-strong)) 94%, var(--success));
|
background: color-mix(in srgb, var(--surface-strong, var(--surface-strong)) 94%, var(--success));
|
||||||
@@ -739,6 +750,10 @@ textarea:focus {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--warning) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-notice.is-error .connection-notice__signal {
|
||||||
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,10 +765,14 @@ textarea:focus {
|
|||||||
width: 3px;
|
width: 3px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: var(--danger);
|
background: var(--warning);
|
||||||
animation: connection-pulse .9s ease-in-out infinite;
|
animation: connection-pulse .9s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connection-notice.is-error .connection-notice__signal span {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.connection-notice__signal span:nth-child(2) {
|
.connection-notice__signal span:nth-child(2) {
|
||||||
animation-delay: .12s;
|
animation-delay: .12s;
|
||||||
}
|
}
|
||||||
@@ -2791,20 +2810,16 @@ dialog::backdrop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.files-dialog {
|
.files-dialog {
|
||||||
|
width: min(940px, calc(100% - 28px));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.files-panel {
|
.files-panel {
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
min-height: 0;
|
||||||
max-height: calc(100vh - 28px);
|
height: min(650px, calc(100dvh - 28px));
|
||||||
}
|
max-height: calc(100dvh - 28px);
|
||||||
|
gap: 14px;
|
||||||
.files-list {
|
|
||||||
|
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.files-head {
|
.files-head {
|
||||||
@@ -2819,10 +2834,17 @@ dialog::backdrop {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.files-head h2 {
|
||||||
|
font-size: 1.08rem;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
}
|
||||||
|
|
||||||
.files-head p {
|
.files-head p {
|
||||||
|
max-width: 620px;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: .8rem;
|
font-size: .8rem;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.files-head-actions {
|
.files-head-actions {
|
||||||
@@ -2833,154 +2855,368 @@ dialog::backdrop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.files-head-actions .primary-button,
|
.files-head-actions .primary-button,
|
||||||
.files-head-actions .action-button {
|
.files-head-actions .secondary-button,
|
||||||
|
.files-head-actions .danger-button {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.files-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(220px, 320px);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 11px;
|
||||||
|
background: color-mix(in srgb, var(--surface-2) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-dialog-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-search {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.files-list {
|
.files-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 9px;
|
align-content: start;
|
||||||
overflow: auto;
|
gap: 10px;
|
||||||
min-height: 80px;
|
min-height: 100px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: 1px 3px 2px 1px;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-row {
|
.file-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 12px;
|
gap: 12px 16px;
|
||||||
padding: 12px;
|
align-items: center;
|
||||||
|
padding: 13px 14px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 9px;
|
border-radius: 12px;
|
||||||
background: var(--control-bg);
|
background: var(--control-bg);
|
||||||
|
transition: border-color .14s ease, background .14s ease, transform .14s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-name {
|
.file-row:not(.file-row--skeleton):hover {
|
||||||
overflow: hidden;
|
border-color: var(--border-strong);
|
||||||
text-overflow: ellipsis;
|
background: color-mix(in srgb, var(--surface-2) 78%, var(--control-bg));
|
||||||
white-space: nowrap;
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-meta {
|
|
||||||
margin-top: 4px;
|
|
||||||
color: var(--muted-2);
|
|
||||||
font-size: .72rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-actions {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-actions button {
|
|
||||||
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--surface-2);
|
|
||||||
color: var(--text);
|
|
||||||
padding: 0 8px;
|
|
||||||
font-size: .72rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
|
||||||
.files-head {
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-head-actions {
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-head-actions .action-button {
|
|
||||||
padding-inline: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-actions {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-panel {
|
|
||||||
min-height: 0;
|
|
||||||
height: min(590px, calc(100vh - 28px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-list {
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-row {
|
|
||||||
align-items: start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-row-main {
|
.file-row-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-actions {
|
.file-copy {
|
||||||
align-items: center;
|
min-width: 0;
|
||||||
align-self: start;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-actions button {
|
.file-name {
|
||||||
min-height: 34px;
|
overflow: hidden;
|
||||||
height: 34px;
|
color: var(--text);
|
||||||
|
font-size: .86rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-code {
|
.file-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px 8px;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--muted-2);
|
||||||
|
font-size: .7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-meta > span:not(.file-flag) + span:not(.file-flag)::before {
|
||||||
|
content: "·";
|
||||||
|
margin-right: 8px;
|
||||||
|
color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-flag {
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions .primary-button {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions button,
|
||||||
|
.file-secondary-actions__buttons button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-primary-action {
|
||||||
|
min-width: 102px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-more-button[aria-expanded="true"] {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-secondary-actions {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: stretch;
|
padding-top: 9px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-secondary-actions[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-secondary-actions__buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-code {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 3px 4px 3px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--surface-2) 74%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-code[hidden] {
|
.file-code[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-code textarea {
|
.file-code-value {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 76px;
|
height: 28px;
|
||||||
max-height: 150px;
|
min-width: 0;
|
||||||
resize: vertical;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
margin: 0;
|
||||||
|
padding: 0 2px;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 28px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-code button {
|
.file-code-value:focus,
|
||||||
align-self: stretch;
|
.file-code-value:focus-visible {
|
||||||
min-width: 74px;
|
border-color: transparent;
|
||||||
|
outline: none;
|
||||||
|
outline-offset: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-secondary-actions [data-show-file-code][aria-pressed="true"] {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-delete-notice {
|
.file-delete-notice {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: 9px;
|
border-radius: 10px;
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: .8rem;
|
font-size: .78rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
.files-state {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
min-height: 250px;
|
||||||
|
padding: 28px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-state strong {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: .88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-state span {
|
||||||
|
max-width: 420px;
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: .76rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-state .primary-button,
|
||||||
|
.files-state .secondary-button,
|
||||||
|
.files-state .danger-button {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-state-mark {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 13px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--accent-text-strong);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-state--error .files-state-mark {
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 45%, var(--border));
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row--skeleton {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-skeleton {
|
||||||
|
display: block;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: linear-gradient(90deg, var(--surface-2), var(--surface-3), var(--surface-2));
|
||||||
|
background-size: 220% 100%;
|
||||||
|
animation: file-skeleton-pulse 1.25s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-skeleton--name {
|
||||||
|
width: min(260px, 80%);
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-skeleton--meta {
|
||||||
|
width: min(360px, 95%);
|
||||||
|
height: 9px;
|
||||||
|
margin-top: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-skeleton--action {
|
||||||
|
width: 132px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes file-skeleton-pulse {
|
||||||
|
from { background-position: 100% 0; }
|
||||||
|
to { background-position: -100% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 2px solid var(--border-strong);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: ui-spinner-rotate .7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-spinner--small {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ui-spinner-rotate {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.file-row,
|
||||||
|
.file-skeleton,
|
||||||
|
.ui-spinner {
|
||||||
|
transition: none;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.files-panel {
|
||||||
|
height: min(720px, calc(100dvh - 18px));
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-head {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-head p {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-head-actions {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-head-actions .primary-button,
|
||||||
|
.files-head-actions .secondary-button,
|
||||||
|
.files-head-actions .danger-button {
|
||||||
|
padding-inline: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-toolbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-search {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.file-actions {
|
.file-actions {
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-code {
|
.file-primary-action {
|
||||||
grid-template-columns: 1fr;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-secondary-actions {
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-insert-dialog {
|
.video-insert-dialog {
|
||||||
@@ -3038,11 +3274,11 @@ dialog::backdrop {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-choice-actions>.action-button--primary span {
|
.video-choice-actions>.primary-button span {
|
||||||
color: var(--on-accent);
|
color: var(--on-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-choice-actions>.action-button--secondary span {
|
.video-choice-actions>.secondary-button span {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5513,6 +5749,31 @@ dialog::backdrop {
|
|||||||
box-shadow: 0 14px 40px var(--shadow-45);
|
box-shadow: 0 14px 40px var(--shadow-45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emoji-loading {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 210px;
|
||||||
|
padding: 22px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
font-size: .78rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-loading[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-loading.is-error {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-picker-content[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.emoji-search {
|
.emoji-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
@@ -5760,16 +6021,6 @@ dialog::backdrop {
|
|||||||
background: var(--danger-hover);
|
background: var(--danger-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-button.compact-button {
|
|
||||||
min-height: 36px;
|
|
||||||
padding: 7px 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-actions button,
|
|
||||||
.file-code button {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-access {
|
.footer-access {
|
||||||
color: var(--muted, var(--muted-fallback));
|
color: var(--muted, var(--muted-fallback));
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
+13
-2
@@ -94,12 +94,18 @@
|
|||||||
<details id="emoji-picker" class="emoji-picker">
|
<details id="emoji-picker" class="emoji-picker">
|
||||||
<summary title="Insert emoji" aria-label="Insert emoji">😀 Emoji</summary>
|
<summary title="Insert emoji" aria-label="Insert emoji">😀 Emoji</summary>
|
||||||
<div class="emoji-picker-panel">
|
<div class="emoji-picker-panel">
|
||||||
|
<div id="emoji-loading" class="emoji-loading" role="status" aria-live="polite">
|
||||||
|
<span class="ui-spinner" aria-hidden="true"></span>
|
||||||
|
<span id="emoji-loading-text" data-i18n="editor.emojiLoading">Loading emoji…</span>
|
||||||
|
</div>
|
||||||
|
<div id="emoji-content" class="emoji-picker-content" hidden>
|
||||||
<input id="emoji-search" class="emoji-search" type="search" placeholder="Search emoji…"
|
<input id="emoji-search" class="emoji-search" type="search" placeholder="Search emoji…"
|
||||||
autocomplete="off" aria-label="Search emoji">
|
autocomplete="off" aria-label="Search emoji">
|
||||||
<div id="emoji-categories" class="emoji-categories" aria-label="Emoji categories"></div>
|
<div id="emoji-categories" class="emoji-categories" aria-label="Emoji categories"></div>
|
||||||
<div id="emoji-grid" class="emoji-grid" role="listbox" aria-label="Emoji"></div>
|
<div id="emoji-grid" class="emoji-grid" role="listbox" aria-label="Emoji"></div>
|
||||||
<p id="emoji-empty" class="emoji-empty" hidden>No emoji found.</p>
|
<p id="emoji-empty" class="emoji-empty" hidden>No emoji found.</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details class="markdown-more">
|
<details class="markdown-more">
|
||||||
<summary title="Extended Markdown">More</summary>
|
<summary title="Extended Markdown">More</summary>
|
||||||
@@ -326,12 +332,17 @@
|
|||||||
<p>Copy a direct link or ready Markdown/Alias code.</p>
|
<p>Copy a direct link or ready Markdown/Alias code.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="files-head-actions">
|
<div class="files-head-actions">
|
||||||
<button id="files-upload-button" class="action-button action-button--primary compact-button"
|
<button id="files-upload-button" class="primary-button"
|
||||||
type="button">Upload file</button>
|
type="button">Upload file</button>
|
||||||
<button id="close-files" class="icon-button" type="button" aria-label="Close files">×</button>
|
<button id="close-files" class="icon-button" type="button" aria-label="Close files">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="files-list" class="files-list"></div>
|
<div class="files-toolbar">
|
||||||
|
<div id="files-dialog-summary" class="files-dialog-summary" aria-live="polite">Loading files…</div>
|
||||||
|
<input id="files-search" class="files-search" type="search" placeholder="Filter files…"
|
||||||
|
autocomplete="off" aria-label="Filter files">
|
||||||
|
</div>
|
||||||
|
<div id="files-list" class="files-list" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="identity-dialog">
|
<dialog id="identity-dialog">
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||||
|
* Source-Available Code / Dual-Licensed.
|
||||||
|
*
|
||||||
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||||
|
* Commercial or production use requires a valid paid license.
|
||||||
|
* See LICENSE file in repository root for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CONNECTION_ERROR_AFTER_RECONNECT_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
export function reconnectStatus(attempt, networkOnline = true) {
|
||||||
|
const normalizedAttempt = Math.max(0, Number(attempt) || 0);
|
||||||
|
if (!networkOnline || normalizedAttempt < CONNECTION_ERROR_AFTER_RECONNECT_ATTEMPTS) return "reconnecting";
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@
|
|||||||
* See LICENSE file in repository root for details.
|
* See LICENSE file in repository root for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EMOJI_GROUPS } from "@rustpad/emoji-data";
|
import { EMOJI_GROUPS } from "@rustpad/emoji-groups";
|
||||||
import { t } from "@rustpad/i18n";
|
import { t } from "@rustpad/i18n";
|
||||||
|
|
||||||
const RECENTS_KEY = "rustpad:recent-emojis";
|
const RECENTS_KEY = "rustpad:recent-emojis";
|
||||||
@@ -153,4 +153,9 @@ export function bindEmojiPicker({ editor, details, search, categories, grid, emp
|
|||||||
document.addEventListener("rustpad:languagechange", () => {
|
document.addEventListener("rustpad:languagechange", () => {
|
||||||
if (details.open) render();
|
if (details.open) render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (details.open) {
|
||||||
|
render();
|
||||||
|
requestAnimationFrame(() => search.focus());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@
|
|||||||
* See LICENSE file in repository root for details.
|
* See LICENSE file in repository root for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EMOJI_SHORTCODES } from "@rustpad/emoji-data";
|
import { EMOJI_SHORTCODES } from "@rustpad/emoji-shortcodes";
|
||||||
import { t } from "@rustpad/i18n";
|
import { t } from "@rustpad/i18n";
|
||||||
import { parseImageAlias } from "@rustpad/image-alias";
|
import { parseImageAlias } from "@rustpad/image-alias";
|
||||||
|
|
||||||
|
|||||||
+110
-25
@@ -17,10 +17,8 @@ import { CollaborationRevisionGapError, CollaborationSession } from "@rustpad/co
|
|||||||
import { copyText } from "@rustpad/clipboard";
|
import { copyText } from "@rustpad/clipboard";
|
||||||
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
|
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
|
||||||
import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format";
|
import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format";
|
||||||
import { bindEmojiPicker } from "@rustpad/emoji-picker";
|
|
||||||
import { updateImageAliasInLineBySource } from "@rustpad/image-alias";
|
import { updateImageAliasInLineBySource } from "@rustpad/image-alias";
|
||||||
import { previewEditingHost } from "@rustpad/preview-edit";
|
import { previewEditingHost } from "@rustpad/preview-edit";
|
||||||
import { bindPdfExport } from "@rustpad/pdf-export";
|
|
||||||
import { createRenderQueue } from "@rustpad/render-queue";
|
import { createRenderQueue } from "@rustpad/render-queue";
|
||||||
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMarkdownFileAliases } from "@rustpad/markdown";
|
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMarkdownFileAliases } from "@rustpad/markdown";
|
||||||
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
|
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
|
||||||
@@ -270,12 +268,7 @@ export function startNoteEditor(adapter) {
|
|||||||
documentTitle.textContent = info.title;
|
documentTitle.textContent = info.title;
|
||||||
}
|
}
|
||||||
globalColor = info.global_color || ""; noteColor = info.note_color || "";
|
globalColor = info.global_color || ""; noteColor = info.note_color || "";
|
||||||
if (getAuthToken()) {
|
if (!getAuthToken()) noteColor = readGuestColor();
|
||||||
const colors = await adapter.loadColor(accountHeaders());
|
|
||||||
globalColor = colors.global_color || ""; noteColor = colors.note_color || "";
|
|
||||||
} else {
|
|
||||||
noteColor = readGuestColor();
|
|
||||||
}
|
|
||||||
updateMarkdownFiles(info.files || []);
|
updateMarkdownFiles(info.files || []);
|
||||||
if (info.personal_editor_settings) {
|
if (info.personal_editor_settings) {
|
||||||
compactToggle.checked = info.compact_view !== false;
|
compactToggle.checked = info.compact_view !== false;
|
||||||
@@ -370,37 +363,55 @@ export function startNoteEditor(adapter) {
|
|||||||
function clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; if (mobileChatUnread) { mobileChatUnread.hidden = true; mobileChatUnread.textContent = ""; } document.title = document.title.replace(/^● /, ""); }
|
function clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; if (mobileChatUnread) { mobileChatUnread.hidden = true; mobileChatUnread.textContent = ""; } document.title = document.title.replace(/^● /, ""); }
|
||||||
|
|
||||||
function setStatus(kind, text) { const className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-dot").className = className; document.querySelector("#status-text").textContent = text; const mobileDot = document.querySelector("#mobile-status-dot"); const mobileText = document.querySelector("#mobile-status-text"); if (mobileDot) mobileDot.className = className; if (mobileText) mobileText.textContent = text; }
|
function setStatus(kind, text) { const className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-dot").className = className; document.querySelector("#status-text").textContent = text; const mobileDot = document.querySelector("#mobile-status-dot"); const mobileText = document.querySelector("#mobile-status-text"); if (mobileDot) mobileDot.className = className; if (mobileText) mobileText.textContent = text; }
|
||||||
function showConnectionNotice(title, message, restored = false) {
|
function showConnectionNotice(title, message, state = "reconnecting") {
|
||||||
clearTimeout(connectionNoticeTimer);
|
clearTimeout(connectionNoticeTimer);
|
||||||
connectionNoticeTitle.textContent = title;
|
connectionNoticeTitle.textContent = title;
|
||||||
connectionNoticeMessage.textContent = message;
|
connectionNoticeMessage.textContent = message;
|
||||||
connectionNotice.hidden = false;
|
connectionNotice.hidden = false;
|
||||||
connectionNotice.classList.toggle("is-restored", restored);
|
connectionNotice.classList.remove("is-restored", "is-error", "is-reconnecting");
|
||||||
|
connectionNotice.classList.add(`is-${state}`);
|
||||||
requestAnimationFrame(() => connectionNotice.classList.add("is-visible"));
|
requestAnimationFrame(() => connectionNotice.classList.add("is-visible"));
|
||||||
if (restored) connectionNoticeTimer = window.setTimeout(() => {
|
if (state === "restored") connectionNoticeTimer = window.setTimeout(() => {
|
||||||
connectionNotice.classList.remove("is-visible", "is-restored");
|
connectionNotice.classList.remove("is-visible", "is-restored");
|
||||||
connectionNoticeTimer = window.setTimeout(() => { connectionNotice.hidden = true; }, 220);
|
connectionNoticeTimer = window.setTimeout(() => { connectionNotice.hidden = true; }, 220);
|
||||||
}, 1800);
|
}, 1800);
|
||||||
}
|
}
|
||||||
function hideConnectionNotice() {
|
function hideConnectionNotice() {
|
||||||
clearTimeout(connectionNoticeTimer);
|
clearTimeout(connectionNoticeTimer);
|
||||||
connectionNotice.classList.remove("is-visible", "is-restored");
|
connectionNotice.classList.remove("is-visible", "is-restored", "is-error", "is-reconnecting");
|
||||||
connectionNotice.hidden = true;
|
connectionNotice.hidden = true;
|
||||||
}
|
}
|
||||||
function handleSocketStatus(status, details = {}) {
|
function handleSocketStatus(status, details = {}) {
|
||||||
if (status === "online") {
|
if (status === "online") {
|
||||||
setStatus("online", "Connected");
|
setStatus("online", t("status.connected", {}, "Connected"));
|
||||||
if (connectionWasInterrupted || details.restored) showConnectionNotice("Connection restored", "Live editing is active again.", true);
|
if (connectionWasInterrupted || details.restored) showConnectionNotice(
|
||||||
|
t("editor.connectionRestored", {}, "Connection restored"),
|
||||||
|
t("editor.connectionRestored.message", {}, "Live editing is active again."),
|
||||||
|
"restored",
|
||||||
|
);
|
||||||
connectionWasInterrupted = false;
|
connectionWasInterrupted = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (status === "error") {
|
||||||
|
connectionWasInterrupted = true;
|
||||||
|
setStatus("error", t("status.connectionError", {}, "Connection error"));
|
||||||
|
const message = details.persistent
|
||||||
|
? t("editor.connectionPersistent", {}, "The live connection is still unavailable. Automatic reconnect continues.")
|
||||||
|
: translateSource(details.message || "The server connection was interrupted.");
|
||||||
|
showConnectionNotice(t("editor.connectionError", {}, "Editor connection error"), message, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (status === "reconnecting") {
|
if (status === "reconnecting") {
|
||||||
connectionWasInterrupted = true;
|
connectionWasInterrupted = true;
|
||||||
setStatus("offline", "Reconnecting…");
|
setStatus("reconnecting", t("status.reconnecting", {}, "Reconnecting..."));
|
||||||
showConnectionNotice("Connection interrupted", details.message || "Trying to reconnect automatically.");
|
showConnectionNotice(
|
||||||
|
t("editor.connectionInterrupted", {}, "Connection interrupted"),
|
||||||
|
translateSource(details.message || t("editor.reconnectAuto", {}, "Trying to reconnect automatically.")),
|
||||||
|
"reconnecting",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus(null, "Connecting…");
|
setStatus("connecting", t("status.connecting", {}, "Connecting..."));
|
||||||
}
|
}
|
||||||
function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; }
|
function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; }
|
||||||
let mermaidRenderVersion = 0;
|
let mermaidRenderVersion = 0;
|
||||||
@@ -409,6 +420,9 @@ export function startNoteEditor(adapter) {
|
|||||||
const nodes = [...preview.querySelectorAll(".mermaid")];
|
const nodes = [...preview.querySelectorAll(".mermaid")];
|
||||||
if (!nodes.length) return;
|
if (!nodes.length) return;
|
||||||
try {
|
try {
|
||||||
|
nodes.forEach(node => {
|
||||||
|
if (!node.dataset.mermaidSource) node.dataset.mermaidSource = node.textContent || "";
|
||||||
|
});
|
||||||
const mermaid = await loadMermaid();
|
const mermaid = await loadMermaid();
|
||||||
mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" });
|
mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" });
|
||||||
await mermaid.run({ nodes });
|
await mermaid.run({ nodes });
|
||||||
@@ -1612,7 +1626,7 @@ export function startNoteEditor(adapter) {
|
|||||||
});
|
});
|
||||||
socket.connect();
|
socket.connect();
|
||||||
}
|
}
|
||||||
bindIdentityDialog({ dialog: identityDialog, onIdentity: async (value, session) => { nickname = value; accountSession = session || await validateCurrentSession(); accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); await syncFavoriteButton(); if (info.protected && info.access_level === "none") passwordDialog.showModal(); else { loadFiles(); connect(); } } });
|
bindIdentityDialog({ dialog: identityDialog, onIdentity: async (value, session) => { nickname = value; accountSession = session || await validateCurrentSession(); accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && info.access_level === "none") passwordDialog.showModal(); else { loadFiles(); connect(); } } });
|
||||||
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
|
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
|
||||||
async function showSystemNotFound() {
|
async function showSystemNotFound() {
|
||||||
try {
|
try {
|
||||||
@@ -1646,7 +1660,6 @@ export function startNoteEditor(adapter) {
|
|||||||
adapter.configureView?.(info);
|
adapter.configureView?.(info);
|
||||||
applyUi({ write: true, replace: true });
|
applyUi({ write: true, replace: true });
|
||||||
updateCurrentUser();
|
updateCurrentUser();
|
||||||
await syncFavoriteButton();
|
|
||||||
if (info.protected && info.access_level === "none") passwordDialog.showModal();
|
if (info.protected && info.access_level === "none") passwordDialog.showModal();
|
||||||
else { loadFiles(); connect(); }
|
else { loadFiles(); connect(); }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -2053,14 +2066,88 @@ export function startNoteEditor(adapter) {
|
|||||||
});
|
});
|
||||||
bindFormatShortcuts(editor);
|
bindFormatShortcuts(editor);
|
||||||
bindIndentationShortcuts(editor, { size: 2 });
|
bindIndentationShortcuts(editor, { size: 2 });
|
||||||
bindPdfExport({
|
const pdfExportArgs = {
|
||||||
button: document.querySelector("#export-pdf"),
|
button: document.querySelector("#export-pdf"),
|
||||||
dialog: document.querySelector("#pdf-export-dialog"),
|
dialog: document.querySelector("#pdf-export-dialog"),
|
||||||
form: document.querySelector("#pdf-export-form"),
|
form: document.querySelector("#pdf-export-form"),
|
||||||
preview,
|
preview,
|
||||||
title: () => document.querySelector("#document-title")?.textContent?.trim() || document.title,
|
title: () => document.querySelector("#document-title")?.textContent?.trim() || document.title,
|
||||||
|
};
|
||||||
|
let pdfExportBound = false;
|
||||||
|
let pdfExportLoad = null;
|
||||||
|
pdfExportArgs.button?.addEventListener("click", async event => {
|
||||||
|
if (pdfExportBound) return;
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
pdfExportLoad ||= import("@rustpad/pdf-export").then(({ bindPdfExport }) => {
|
||||||
|
bindPdfExport(pdfExportArgs);
|
||||||
|
pdfExportBound = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await pdfExportLoad;
|
||||||
|
if (!pdfExportArgs.dialog.open) pdfExportArgs.dialog.showModal();
|
||||||
|
} catch (error) {
|
||||||
|
pdfExportLoad = null;
|
||||||
|
console.error("Failed to load PDF export", error);
|
||||||
|
toast.danger(t("editor.pdfExportFailed", {}, "Could not prepare the PDF."), {
|
||||||
|
title: t("editor.exportPdf", {}, "Export PDF"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emojiPickerArgs = {
|
||||||
|
editor,
|
||||||
|
details: document.querySelector("#emoji-picker"),
|
||||||
|
search: document.querySelector("#emoji-search"),
|
||||||
|
categories: document.querySelector("#emoji-categories"),
|
||||||
|
grid: document.querySelector("#emoji-grid"),
|
||||||
|
empty: document.querySelector("#emoji-empty"),
|
||||||
|
};
|
||||||
|
const emojiLoading = document.querySelector("#emoji-loading");
|
||||||
|
const emojiLoadingText = document.querySelector("#emoji-loading-text");
|
||||||
|
const emojiContent = document.querySelector("#emoji-content");
|
||||||
|
let emojiPickerLoad = null;
|
||||||
|
let emojiPickerBound = false;
|
||||||
|
|
||||||
|
const setEmojiPickerState = state => {
|
||||||
|
const failed = state === "error";
|
||||||
|
const ready = state === "ready";
|
||||||
|
emojiPickerArgs.details?.setAttribute("aria-busy", String(state === "loading"));
|
||||||
|
if (emojiLoading) {
|
||||||
|
emojiLoading.hidden = ready;
|
||||||
|
emojiLoading.classList.toggle("is-error", failed);
|
||||||
|
const spinner = emojiLoading.querySelector(".ui-spinner");
|
||||||
|
if (spinner) spinner.hidden = failed;
|
||||||
|
}
|
||||||
|
if (emojiLoadingText) {
|
||||||
|
emojiLoadingText.textContent = failed
|
||||||
|
? t("editor.emojiLoadFailed", {}, "Could not load emoji. Close and reopen the picker to retry.")
|
||||||
|
: t("editor.emojiLoading", {}, "Loading emoji…");
|
||||||
|
}
|
||||||
|
if (emojiContent) emojiContent.hidden = !ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadEmojiPicker = async () => {
|
||||||
|
if (emojiPickerBound) {
|
||||||
|
setEmojiPickerState("ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (emojiPickerLoad) return emojiPickerLoad;
|
||||||
|
setEmojiPickerState("loading");
|
||||||
|
emojiPickerLoad = import("@rustpad/emoji-picker").then(({ bindEmojiPicker }) => {
|
||||||
|
bindEmojiPicker(emojiPickerArgs);
|
||||||
|
emojiPickerBound = true;
|
||||||
|
setEmojiPickerState("ready");
|
||||||
|
}).catch(error => {
|
||||||
|
emojiPickerLoad = null;
|
||||||
|
setEmojiPickerState("error");
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return emojiPickerLoad;
|
||||||
|
};
|
||||||
|
emojiPickerArgs.details?.addEventListener("toggle", () => {
|
||||||
|
if (!emojiPickerArgs.details.open) return;
|
||||||
|
void loadEmojiPicker().catch(error => console.error("Failed to load emoji picker", error));
|
||||||
});
|
});
|
||||||
bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") });
|
|
||||||
document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal());
|
document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal());
|
||||||
document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close());
|
document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close());
|
||||||
|
|
||||||
@@ -2238,7 +2325,7 @@ export function startNoteEditor(adapter) {
|
|||||||
password = "";
|
password = "";
|
||||||
setPagePasswordInput.value = "";
|
setPagePasswordInput.value = "";
|
||||||
await loadNoteInfo();
|
await loadNoteInfo();
|
||||||
await syncFavoriteButton();
|
void syncFavoriteButton();
|
||||||
resourceUnlocked = false;
|
resourceUnlocked = false;
|
||||||
socket?.stop();
|
socket?.stop();
|
||||||
loadFiles();
|
loadFiles();
|
||||||
@@ -2336,11 +2423,9 @@ export function startNoteEditor(adapter) {
|
|||||||
document.querySelector("#open-password")?.focus();
|
document.querySelector("#open-password")?.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); await syncFavoriteButton(); loadFiles(); connect(); toast.success("Editing access has been unlocked.", { title: "Note unlocked" }); } catch (error) { document.querySelector("#password-error").textContent = error.message; toast.danger(error.message, { title: "Could not unlock note" }); } });
|
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); void syncFavoriteButton(); loadFiles(); connect(); toast.success("Editing access has been unlocked.", { title: "Note unlocked" }); } catch (error) { document.querySelector("#password-error").textContent = error.message; toast.danger(error.message, { title: "Could not unlock note" }); } });
|
||||||
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast.success("The selected revision is now the current version.", { title: "Version restored" }); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; toast.danger(e.message, { title: "Could not load version history" }); } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
|
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast.success("The selected revision is now the current version.", { title: "Version restored" }); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; toast.danger(e.message, { title: "Could not load version history" }); } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
|
||||||
|
|
||||||
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast.danger(error.message, { title: "Could not delete note" }); } });
|
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast.danger(error.message, { title: "Could not delete note" }); } });
|
||||||
window.addEventListener("error", event => { setStatus("offline", "Application error"); console.error(event.error || event.message); });
|
|
||||||
window.addEventListener("unhandledrejection", event => { setStatus("offline", "Application error"); console.error(event.reason); });
|
|
||||||
initialize();
|
initialize();
|
||||||
}
|
}
|
||||||
|
|||||||
+150
-38
@@ -7,7 +7,7 @@
|
|||||||
* See LICENSE file in repository root for details.
|
* See LICENSE file in repository root for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { formatDateTime, formatNumber, tp } from "@rustpad/i18n";
|
import { formatDateTime, formatNumber, t, tp } from "@rustpad/i18n";
|
||||||
|
|
||||||
import { api, uploadWithProgress } from "@rustpad/api";
|
import { api, uploadWithProgress } from "@rustpad/api";
|
||||||
import { copyText } from "@rustpad/clipboard";
|
import { copyText } from "@rustpad/clipboard";
|
||||||
@@ -103,10 +103,10 @@ function createVideoInsertDialog() {
|
|||||||
</div>
|
</div>
|
||||||
<p class="dialog-copy" data-video-file-name></p>
|
<p class="dialog-copy" data-video-file-name></p>
|
||||||
<div class="video-choice-actions">
|
<div class="video-choice-actions">
|
||||||
<button type="button" class="action-button action-button--primary" data-video-choice="player">
|
<button type="button" class="primary-button" data-video-choice="player">
|
||||||
<strong>Embedded player</strong><span>Play the video directly in the note.</span>
|
<strong>Embedded player</strong><span>Play the video directly in the note.</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="action-button action-button--secondary" data-video-choice="download">
|
<button type="button" class="secondary-button" data-video-choice="download">
|
||||||
<strong>Download link</strong><span>Insert a link that downloads the original file.</span>
|
<strong>Download link</strong><span>Insert a link that downloads the original file.</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,46 +155,134 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
|||||||
|
|
||||||
function fileActionButtons(file) {
|
function fileActionButtons(file) {
|
||||||
const attributes = `data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}"`;
|
const attributes = `data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}"`;
|
||||||
const insertButtons = isVideo(file.mime_type)
|
const primaryInsert = 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="primary-button file-primary-action" data-add-file-to-note data-insert-mode="player" ${attributes}>${escapeHtml(t("files.addPlayer", {}, "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="primary-button file-primary-action" data-add-file-to-note data-insert-mode="auto" ${attributes}>${escapeHtml(t("files.addToNote", {}, "Add to note"))}</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 secondaryInsert = isVideo(file.mime_type)
|
||||||
const codeButtons = isVideo(file.mime_type)
|
? `<button class="secondary-button" data-add-file-to-note data-insert-mode="download" ${attributes}>${escapeHtml(t("files.addDownload", {}, "Add download"))}</button>`
|
||||||
? `<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}`;
|
const codeButtons = isVideo(file.mime_type)
|
||||||
|
? `<button class="secondary-button" data-show-file-code="link" aria-pressed="false" ${attributes}>${escapeHtml(t("files.link", {}, "Link"))}</button>
|
||||||
|
<button class="secondary-button" data-show-file-code="player" aria-pressed="false" ${attributes}>${escapeHtml(t("files.playerCode", {}, "Player code"))}</button>
|
||||||
|
<button class="secondary-button" data-show-file-code="download" aria-pressed="false" ${attributes}>${escapeHtml(t("files.downloadCode", {}, "Download code"))}</button>`
|
||||||
|
: `<button class="secondary-button" data-show-file-code="link" aria-pressed="false" ${attributes}>${escapeHtml(t("files.link", {}, "Link"))}</button>
|
||||||
|
<button class="secondary-button" data-show-file-code="alias" aria-pressed="false" ${attributes}>${escapeHtml(t("files.alias", {}, "Alias"))}</button>
|
||||||
|
<button class="secondary-button" data-show-file-code="markdown" aria-pressed="false" ${attributes}>${escapeHtml(t("files.markdown", {}, "Markdown"))}</button>`;
|
||||||
|
const deleteButton = canDelete()
|
||||||
|
? `<button class="danger-button" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">${escapeHtml(t("files.delete", {}, "Delete file"))}</button>`
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
primary: `${primaryInsert}<button class="secondary-button file-more-button" type="button" data-toggle-file-actions aria-expanded="false">${escapeHtml(t("common.more", {}, "More"))}</button>`,
|
||||||
|
secondary: `${secondaryInsert}${codeButtons}${deleteButton}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFiles({ open = false } = {}) {
|
const summary = document.querySelector("#files-dialog-summary");
|
||||||
try {
|
const search = document.querySelector("#files-search");
|
||||||
const files = await api(endpoints.list, { method: "PUT", body: JSON.stringify({ access_token: getAccessToken() || null }) });
|
let currentFiles = [];
|
||||||
|
let filesLoaded = false;
|
||||||
|
let loadSequence = 0;
|
||||||
|
|
||||||
|
function summaryText(files, shown = files.length) {
|
||||||
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
|
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
|
||||||
footer.textContent = tp("files.summary", files.length, { size: formatBytes(totalSize) }, `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`);
|
if (shown !== files.length) {
|
||||||
|
return t("files.filteredSummary", { shown, count: files.length, size: formatBytes(totalSize) }, `${shown} of ${files.length} files · ${formatBytes(totalSize)}`);
|
||||||
|
}
|
||||||
|
return tp("files.summary", files.length, { size: formatBytes(totalSize) }, `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileSearchText(file) {
|
||||||
|
return `${file.filename || ""} ${file.mime_type || ""}`.toLocaleLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileRow(file) {
|
||||||
|
const created = file.created_at ? formatDate(file.created_at) : "";
|
||||||
|
const status = file.is_attached ? t("files.inNote", {}, "in note") : t("files.removedContent", {}, "removed from content");
|
||||||
|
const generatedCodeLabel = t("files.generatedCode", {}, "Generated file code");
|
||||||
|
const actions = fileActionButtons(file);
|
||||||
|
return `
|
||||||
|
<article class="file-row" data-file-row="${file.id}" data-file-search="${escapeHtml(fileSearchText(file))}">
|
||||||
|
<div class="file-row-main">
|
||||||
|
<div class="file-copy">
|
||||||
|
<div class="file-name" title="${escapeHtml(file.filename)}">${escapeHtml(file.filename)}</div>
|
||||||
|
<div class="file-meta">
|
||||||
|
<span>${formatBytes(file.size_bytes)}</span>
|
||||||
|
<span>${escapeHtml(file.mime_type || "file")}</span>
|
||||||
|
${created ? `<span>${escapeHtml(created)}</span>` : ""}
|
||||||
|
<span class="file-flag${file.is_attached ? "" : " detached"}">${escapeHtml(status)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-actions">${actions.primary}</div>
|
||||||
|
<div class="file-secondary-actions" hidden>
|
||||||
|
<div class="file-secondary-actions__buttons">${actions.secondary}</div>
|
||||||
|
<div class="file-code" hidden>
|
||||||
|
<input class="file-code-value" type="text" readonly aria-label="${escapeHtml(generatedCodeLabel)}" spellcheck="false">
|
||||||
|
<button class="secondary-button file-code-copy" data-copy-generated>${escapeHtml(t("share.copy", {}, "Copy"))}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFiles() {
|
||||||
|
if (!filesLoaded) return;
|
||||||
|
if (search) search.disabled = false;
|
||||||
|
const query = String(search?.value || "").trim().toLocaleLowerCase();
|
||||||
const restrictionMessage = getDeleteRestrictionMessage();
|
const restrictionMessage = getDeleteRestrictionMessage();
|
||||||
const restrictionNotice = restrictionMessage
|
const restrictionNotice = restrictionMessage
|
||||||
? `<p class="file-delete-notice">${escapeHtml(restrictionMessage)}</p>`
|
? `<p class="file-delete-notice">${escapeHtml(restrictionMessage)}</p>`
|
||||||
: "";
|
: "";
|
||||||
const fileRows = files.length ? files.map(file => `
|
const visible = query ? currentFiles.filter(file => fileSearchText(file).includes(query)) : currentFiles;
|
||||||
<div class="file-row" data-file-row="${file.id}">
|
if (summary) summary.textContent = summaryText(currentFiles, visible.length);
|
||||||
<div class="file-row-main">
|
|
||||||
<div class="file-name">${escapeHtml(file.filename)}</div>
|
if (!currentFiles.length) {
|
||||||
<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>
|
list.innerHTML = `${restrictionNotice}<div class="files-state files-state--empty"><div class="files-state-mark" aria-hidden="true">+</div><strong>${escapeHtml(t("files.empty", {}, "No files uploaded."))}</strong><span>${escapeHtml(t("files.emptyHelp", {}, "Upload a file to keep it with this note and insert it into the document."))}</span></div>`;
|
||||||
</div>
|
return;
|
||||||
<div class="file-actions">${fileActionButtons(file)}</div>
|
}
|
||||||
<div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button class="action-button action-button--primary compact-button" data-copy-generated>Copy</button></div>
|
if (!visible.length) {
|
||||||
</div>`).join("") : '<p class="dialog-copy">No files uploaded.</p>';
|
list.innerHTML = `${restrictionNotice}<div class="files-state files-state--empty"><div class="files-state-mark files-state-mark--search" aria-hidden="true">⌕</div><strong>${escapeHtml(t("files.noMatches", {}, "No files match this filter."))}</strong><span>${escapeHtml(t("files.noMatchesHelp", {}, "Try a different file name or type."))}</span></div>`;
|
||||||
list.innerHTML = restrictionNotice + fileRows;
|
return;
|
||||||
onFilesChanged(files);
|
}
|
||||||
|
list.innerHTML = restrictionNotice + visible.map(fileRow).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoading({ refreshing = false } = {}) {
|
||||||
|
if (search && !filesLoaded) search.disabled = true;
|
||||||
|
if (summary) {
|
||||||
|
summary.innerHTML = `<span class="ui-spinner ui-spinner--small" aria-hidden="true"></span><span>${escapeHtml(t(refreshing ? "files.refreshing" : "files.loading", {}, refreshing ? "Refreshing…" : "Loading files…"))}</span>`;
|
||||||
|
}
|
||||||
|
if (refreshing && filesLoaded) return;
|
||||||
|
list.innerHTML = Array.from({ length: 3 }, () => `
|
||||||
|
<div class="file-row file-row--skeleton" aria-hidden="true">
|
||||||
|
<div class="file-row-main"><div class="file-copy"><span class="file-skeleton file-skeleton--name"></span><span class="file-skeleton file-skeleton--meta"></span></div></div>
|
||||||
|
<span class="file-skeleton file-skeleton--action"></span>
|
||||||
|
</div>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoadError(error) {
|
||||||
|
if (search && !filesLoaded) search.disabled = true;
|
||||||
|
if (summary) summary.textContent = t("files.loadFailed", {}, "Could not load files");
|
||||||
|
if (filesLoaded) return;
|
||||||
|
list.innerHTML = `<div class="files-state files-state--error"><div class="files-state-mark" aria-hidden="true">!</div><strong>${escapeHtml(t("files.loadFailed", {}, "Could not load files"))}</strong><span>${escapeHtml(error?.message || "")}</span><button class="secondary-button" type="button" data-files-retry>${escapeHtml(t("files.retry", {}, "Try again"))}</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles({ open = false } = {}) {
|
||||||
if (open && !dialog.open) dialog.showModal();
|
if (open && !dialog.open) dialog.showModal();
|
||||||
|
const sequence = ++loadSequence;
|
||||||
|
renderLoading({ refreshing: filesLoaded });
|
||||||
|
try {
|
||||||
|
const files = await api(endpoints.list, { method: "PUT", body: JSON.stringify({ access_token: getAccessToken() || null }) });
|
||||||
|
if (sequence !== loadSequence) return;
|
||||||
|
currentFiles = Array.isArray(files) ? files : [];
|
||||||
|
filesLoaded = true;
|
||||||
|
const totalSize = currentFiles.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
|
||||||
|
footer.textContent = tp("files.summary", currentFiles.length, { size: formatBytes(totalSize) }, `${currentFiles.length} ${currentFiles.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`);
|
||||||
|
renderFiles();
|
||||||
|
onFilesChanged(currentFiles);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (open) notify("danger", error.message, { title: "Could not load files" });
|
if (sequence !== loadSequence) return;
|
||||||
|
renderLoadError(error);
|
||||||
|
if (open) notify("danger", error.message, { title: t("files.loadFailed", {}, "Could not load files") });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +417,31 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
|||||||
document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true }));
|
document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true }));
|
||||||
footer.addEventListener("click", () => loadFiles({ open: true }));
|
footer.addEventListener("click", () => loadFiles({ open: true }));
|
||||||
document.querySelector("#close-files").addEventListener("click", () => dialog.close());
|
document.querySelector("#close-files").addEventListener("click", () => dialog.close());
|
||||||
|
search?.addEventListener("input", renderFiles);
|
||||||
list.addEventListener("click", async event => {
|
list.addEventListener("click", async event => {
|
||||||
|
const retryButton = event.target.closest("[data-files-retry]");
|
||||||
|
if (retryButton) {
|
||||||
|
void loadFiles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleButton = event.target.closest("[data-toggle-file-actions]");
|
||||||
|
if (toggleButton) {
|
||||||
|
const row = toggleButton.closest(".file-row");
|
||||||
|
const panel = row?.querySelector(".file-secondary-actions");
|
||||||
|
if (!panel) return;
|
||||||
|
const expanded = panel.hidden;
|
||||||
|
panel.hidden = !expanded;
|
||||||
|
toggleButton.setAttribute("aria-expanded", String(expanded));
|
||||||
|
toggleButton.textContent = expanded ? t("files.less", {}, "Less") : t("common.more", {}, "More");
|
||||||
|
if (!expanded) {
|
||||||
|
const codePanel = row.querySelector(".file-code");
|
||||||
|
if (codePanel) codePanel.hidden = true;
|
||||||
|
row.querySelectorAll('[data-show-file-code][aria-pressed="true"]').forEach(button => button.setAttribute("aria-pressed", "false"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
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 mode = addButton.dataset.insertMode;
|
const mode = addButton.dataset.insertMode;
|
||||||
@@ -342,8 +454,9 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
|||||||
|
|
||||||
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 row = showButton.closest(".file-row");
|
||||||
const output = panel.querySelector("textarea");
|
const panel = row.querySelector(".file-code");
|
||||||
|
const output = panel.querySelector(".file-code-value");
|
||||||
const safeUrl = safeAttachmentUrl(showButton.dataset.url);
|
const safeUrl = safeAttachmentUrl(showButton.dataset.url);
|
||||||
const absolute = new URL(safeUrl, location.origin).href;
|
const absolute = new URL(safeUrl, location.origin).href;
|
||||||
let text = showButton.dataset.showFileCode === "link" && isVideo(showButton.dataset.mime)
|
let text = showButton.dataset.showFileCode === "link" && isVideo(showButton.dataset.mime)
|
||||||
@@ -360,15 +473,14 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
|||||||
}
|
}
|
||||||
output.value = text;
|
output.value = text;
|
||||||
panel.hidden = false;
|
panel.hidden = false;
|
||||||
output.focus();
|
row.querySelectorAll("[data-show-file-code]").forEach(button => button.setAttribute("aria-pressed", String(button === showButton)));
|
||||||
output.select();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyButton = event.target.closest("[data-copy-generated]");
|
const copyButton = event.target.closest("[data-copy-generated]");
|
||||||
if (copyButton) {
|
if (copyButton) {
|
||||||
try {
|
try {
|
||||||
await copyText(copyButton.closest(".file-code").querySelector("textarea").value);
|
await copyText(copyButton.closest(".file-code").querySelector(".file-code-value").value);
|
||||||
notify("success", "Generated file code copied to the clipboard.", { title: "Copied" });
|
notify("success", "Generated file code copied to the clipboard.", { title: "Copied" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify("danger", error.message, { title: "Could not copy file code" });
|
notify("danger", error.message, { title: "Could not copy file code" });
|
||||||
|
|||||||
+163
-4
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { t } from "@rustpad/i18n";
|
import { t } from "@rustpad/i18n";
|
||||||
import { toast } from "@rustpad/toast";
|
import { toast } from "@rustpad/toast";
|
||||||
import { loadHighlight } from "@rustpad/vendor-libs";
|
import { loadHighlight, loadMermaid } from "@rustpad/vendor-libs";
|
||||||
|
|
||||||
const STORAGE_PREFIX = "rustpad:pdf-export:";
|
const STORAGE_PREFIX = "rustpad:pdf-export:";
|
||||||
|
|
||||||
@@ -122,6 +122,126 @@ async function waitForPrintableLayout(doc) {
|
|||||||
await nextFrame(doc.defaultView);
|
await nextFrame(doc.defaultView);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBlankPrintLine(node) {
|
||||||
|
if (!(node instanceof Element) || !node.classList.contains("preview-source-line")) return false;
|
||||||
|
if (node.textContent.trim()) return false;
|
||||||
|
return node.children.length === 1 && node.firstElementChild?.tagName === "BR";
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseHeadingGaps(root) {
|
||||||
|
root.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach(heading => {
|
||||||
|
let next = heading.nextElementSibling;
|
||||||
|
while (isBlankPrintLine(next)) {
|
||||||
|
const remove = next;
|
||||||
|
next = next.nextElementSibling;
|
||||||
|
remove.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function directPrintBlock(root, node) {
|
||||||
|
let block = node;
|
||||||
|
while (block?.parentElement && block.parentElement !== root) block = block.parentElement;
|
||||||
|
return block?.parentElement === root ? block : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortLeadBlock(node) {
|
||||||
|
if (!node) return false;
|
||||||
|
if (node.matches("h1, h2, h3, h4, h5, h6")) return true;
|
||||||
|
if (!node.matches("p")) return false;
|
||||||
|
const text = node.textContent.trim();
|
||||||
|
return text.length > 0 && text.length <= 320;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousNonBlank(node) {
|
||||||
|
let current = node?.previousElementSibling || null;
|
||||||
|
while (isBlankPrintLine(current)) current = current.previousElementSibling;
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupAtomicVisuals(root) {
|
||||||
|
const blocks = new Set();
|
||||||
|
root.querySelectorAll(".mermaid, .rustpad-media").forEach(node => {
|
||||||
|
const block = directPrintBlock(root, node);
|
||||||
|
if (block) blocks.add(block);
|
||||||
|
});
|
||||||
|
root.querySelectorAll("img").forEach(node => {
|
||||||
|
const block = directPrintBlock(root, node);
|
||||||
|
if (block && !block.textContent.trim()) blocks.add(block);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.parentElement !== root) continue;
|
||||||
|
block.classList.add("pdf-atomic-visual");
|
||||||
|
|
||||||
|
let first = block;
|
||||||
|
const lead = previousNonBlank(block);
|
||||||
|
if (shortLeadBlock(lead)) {
|
||||||
|
first = lead;
|
||||||
|
if (lead.matches("p")) {
|
||||||
|
const heading = previousNonBlank(lead);
|
||||||
|
if (heading?.matches("h1, h2, h3, h4, h5, h6")) first = heading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first === block) continue;
|
||||||
|
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "pdf-keep-unit";
|
||||||
|
root.insertBefore(wrapper, first);
|
||||||
|
|
||||||
|
let current = first;
|
||||||
|
while (current) {
|
||||||
|
const next = current.nextElementSibling;
|
||||||
|
if (isBlankPrintLine(current)) current.remove();
|
||||||
|
else wrapper.appendChild(current);
|
||||||
|
if (current === block) break;
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function preparePrintPagination(root) {
|
||||||
|
collapseHeadingGaps(root);
|
||||||
|
groupAtomicVisuals(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePrintableMermaid(root) {
|
||||||
|
const nodes = [...root.querySelectorAll(".mermaid[data-mermaid-source]")];
|
||||||
|
if (!nodes.length) return;
|
||||||
|
|
||||||
|
const originals = nodes.map(node => ({
|
||||||
|
node,
|
||||||
|
html: node.innerHTML,
|
||||||
|
processed: node.getAttribute("data-processed"),
|
||||||
|
}));
|
||||||
|
const staging = document.createElement("div");
|
||||||
|
staging.setAttribute("aria-hidden", "true");
|
||||||
|
staging.style.cssText = "position:fixed;left:-200vw;top:0;width:1200px;visibility:hidden;pointer-events:none;z-index:-1";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mermaid = await loadMermaid();
|
||||||
|
nodes.forEach(node => {
|
||||||
|
node.removeAttribute("data-processed");
|
||||||
|
node.textContent = node.dataset.mermaidSource || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
staging.appendChild(root);
|
||||||
|
document.body.appendChild(staging);
|
||||||
|
mermaid.initialize({ startOnLoad: false, theme: "default", securityLevel: "strict" });
|
||||||
|
await mermaid.run({ nodes });
|
||||||
|
} catch {
|
||||||
|
originals.forEach(({ node, html, processed }) => {
|
||||||
|
node.innerHTML = html;
|
||||||
|
if (processed === null) node.removeAttribute("data-processed");
|
||||||
|
else node.setAttribute("data-processed", processed);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (root.parentElement === staging) staging.removeChild(root);
|
||||||
|
staging.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function measureLastPageFill(doc, documentRoot, pageContentHeight) {
|
function measureLastPageFill(doc, documentRoot, pageContentHeight) {
|
||||||
const content = documentRoot.querySelector(":scope > .pdf-content");
|
const content = documentRoot.querySelector(":scope > .pdf-content");
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
@@ -254,13 +374,14 @@ function preparePreview(preview) {
|
|||||||
marker.replaceWith(pageBreak);
|
marker.replaceWith(pageBreak);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
preparePrintPagination(clone);
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, preserveColors) {
|
function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, preserveColors) {
|
||||||
const pageContentWidthMm = Math.max(20, Number(pageWidthMm) - (2 * Number(marginMm)));
|
const pageContentWidthMm = Math.max(20, Number(pageWidthMm) - (2 * Number(marginMm)));
|
||||||
const pageContentHeightMm = Math.max(20, Number(pageHeightMm) - (2 * Number(marginMm)));
|
const pageContentHeightMm = Math.max(20, Number(pageHeightMm) - (2 * Number(marginMm)));
|
||||||
const atomicMaxHeightMm = Math.max(20, pageContentHeightMm - 8);
|
const atomicMaxHeightMm = Math.max(20, pageContentHeightMm - 20);
|
||||||
const mediaMaxWidthMm = atomicMaxHeightMm * (16 / 9);
|
const mediaMaxWidthMm = atomicMaxHeightMm * (16 / 9);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -306,15 +427,27 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, preserveColor
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.pdf-content .preview-source-line::before { display: none !important; content: none !important; }
|
.pdf-content .preview-source-line::before { display: none !important; content: none !important; }
|
||||||
.pdf-content .preview-source-line { min-height: 0; }
|
.pdf-content .preview-source-line { min-height: 0; position: static !important; }
|
||||||
.pdf-content .preview-editable:hover,
|
.pdf-content .preview-editable:hover,
|
||||||
.pdf-content .preview-editable:focus { background: transparent !important; box-shadow: none !important; }
|
.pdf-content .preview-editable:focus { background: transparent !important; box-shadow: none !important; }
|
||||||
.pdf-content a { color: inherit !important; text-decoration: underline; }
|
.pdf-content a { color: inherit !important; text-decoration: underline; }
|
||||||
|
|
||||||
/* Book-like pagination: keep small semantic units together, not whole sections. */
|
/* Book-like pagination: keep small semantic units together, not whole sections. */
|
||||||
|
/*
|
||||||
|
* Do not rely on break-after: avoid for the main Markdown headings.
|
||||||
|
* Chromium can first paint the heading at the end of the old page and
|
||||||
|
* then move it to the next page, leaving a clipped strip of glyphs behind.
|
||||||
|
* A small invisible reserve inside the heading keeps one following line
|
||||||
|
* with it without triggering that fragmentation bug.
|
||||||
|
*/
|
||||||
.pdf-content h1,
|
.pdf-content h1,
|
||||||
.pdf-content h2,
|
.pdf-content h2,
|
||||||
.pdf-content h3,
|
.pdf-content h3 {
|
||||||
|
break-after: auto;
|
||||||
|
page-break-after: auto;
|
||||||
|
padding-bottom: 1.15em;
|
||||||
|
margin-bottom: calc(.18em - 1.15em);
|
||||||
|
}
|
||||||
.pdf-content h4,
|
.pdf-content h4,
|
||||||
.pdf-content h5,
|
.pdf-content h5,
|
||||||
.pdf-content h6,
|
.pdf-content h6,
|
||||||
@@ -396,6 +529,31 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, preserveColor
|
|||||||
-webkit-box-decoration-break: clone;
|
-webkit-box-decoration-break: clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pdf-content .pdf-keep-unit {
|
||||||
|
/*
|
||||||
|
* Chromium can leave a 1-2 px fragment of an avoided block on the
|
||||||
|
* previous page before moving the real block to the next page.
|
||||||
|
* Making the keep unit an atomic inline-block prevents that ghost
|
||||||
|
* fragment while preserving book-like pagination.
|
||||||
|
*/
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
vertical-align: top;
|
||||||
|
box-sizing: border-box;
|
||||||
|
break-inside: avoid-page;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
.pdf-content .pdf-keep-unit > :first-child { margin-top: 0 !important; }
|
||||||
|
.pdf-content .pdf-keep-unit > :last-child { margin-bottom: 0 !important; }
|
||||||
|
.pdf-content .pdf-atomic-visual {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
vertical-align: top;
|
||||||
|
box-sizing: border-box;
|
||||||
|
break-before: avoid-page;
|
||||||
|
page-break-before: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
/* Atomic visual items are scaled to the printable page instead of being fragmented. */
|
/* Atomic visual items are scaled to the printable page instead of being fragmented. */
|
||||||
.pdf-content img {
|
.pdf-content img {
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
@@ -517,6 +675,7 @@ function openPrintWindow({ preview, title, formatKey, marginMm, includeTitle, pr
|
|||||||
const footerText = escapeHtml(t("editor.pdfGeneratedFooter", {}, "Generated by RustPad"));
|
const footerText = escapeHtml(t("editor.pdfGeneratedFooter", {}, "Generated by RustPad"));
|
||||||
|
|
||||||
return (async () => {
|
return (async () => {
|
||||||
|
await ensurePrintableMermaid(content);
|
||||||
if (preserveColors) await ensureSyntaxHighlight(content);
|
if (preserveColors) await ensureSyntaxHighlight(content);
|
||||||
|
|
||||||
printWindow.document.open();
|
printWindow.document.open();
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ async function renderMermaid() {
|
|||||||
const nodes = [...content.querySelectorAll(".mermaid")];
|
const nodes = [...content.querySelectorAll(".mermaid")];
|
||||||
if (!nodes.length) return;
|
if (!nodes.length) return;
|
||||||
try {
|
try {
|
||||||
|
nodes.forEach(node => {
|
||||||
|
if (!node.dataset.mermaidSource) node.dataset.mermaidSource = node.textContent || "";
|
||||||
|
});
|
||||||
const mermaid = await loadMermaid();
|
const mermaid = await loadMermaid();
|
||||||
mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" });
|
mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" });
|
||||||
await mermaid.run({ nodes });
|
await mermaid.run({ nodes });
|
||||||
|
|||||||
+21
-3
@@ -7,6 +7,7 @@
|
|||||||
* See LICENSE file in repository root for details.
|
* See LICENSE file in repository root for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { reconnectStatus } from "@rustpad/connection-state";
|
||||||
import { logError, logInfo, logWarn } from "@rustpad/logger";
|
import { logError, logInfo, logWarn } from "@rustpad/logger";
|
||||||
|
|
||||||
const DEFAULT_HEARTBEAT_INTERVAL_MS = 10000;
|
const DEFAULT_HEARTBEAT_INTERVAL_MS = 10000;
|
||||||
@@ -68,8 +69,12 @@ class RoomSocket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.intentionalClose = false;
|
this.intentionalClose = false;
|
||||||
this.onStatus?.(this.reconnectAttempt ? "reconnecting" : "connecting", {
|
const status = this.reconnectAttempt
|
||||||
|
? reconnectStatus(this.reconnectAttempt, navigator.onLine)
|
||||||
|
: "connecting";
|
||||||
|
this.onStatus?.(status, {
|
||||||
attempt: this.reconnectAttempt,
|
attempt: this.reconnectAttempt,
|
||||||
|
persistent: status === "error",
|
||||||
message: this.reconnectAttempt ? "Re-establishing the live connection." : "Opening the live connection.",
|
message: this.reconnectAttempt ? "Re-establishing the live connection." : "Opening the live connection.",
|
||||||
});
|
});
|
||||||
this.emitDiagnostics();
|
this.emitDiagnostics();
|
||||||
@@ -122,6 +127,7 @@ class RoomSocket {
|
|||||||
}
|
}
|
||||||
if (message.type === "error") {
|
if (message.type === "error") {
|
||||||
this.intentionalClose = true;
|
this.intentionalClose = true;
|
||||||
|
this.onStatus?.("error", { message: message.message, fatal: true });
|
||||||
this.onError?.(message.message);
|
this.onError?.(message.message);
|
||||||
socket.close();
|
socket.close();
|
||||||
return;
|
return;
|
||||||
@@ -204,7 +210,13 @@ class RoomSocket {
|
|||||||
const baseDelay = Math.min(this.maxReconnectDelayMs, 750 * (2 ** Math.min(this.reconnectAttempt - 1, 4)));
|
const baseDelay = Math.min(this.maxReconnectDelayMs, 750 * (2 ** Math.min(this.reconnectAttempt - 1, 4)));
|
||||||
const retryInMs = navigator.onLine ? baseDelay : 3000;
|
const retryInMs = navigator.onLine ? baseDelay : 3000;
|
||||||
this.totalReconnects += 1;
|
this.totalReconnects += 1;
|
||||||
this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs });
|
const status = reconnectStatus(this.reconnectAttempt, navigator.onLine);
|
||||||
|
this.onStatus?.(status, {
|
||||||
|
attempt: this.reconnectAttempt,
|
||||||
|
persistent: status === "error",
|
||||||
|
message,
|
||||||
|
retryInMs,
|
||||||
|
});
|
||||||
this.emitDiagnostics();
|
this.emitDiagnostics();
|
||||||
this.reconnectTimer = window.setTimeout(() => this.connect(), retryInMs);
|
this.reconnectTimer = window.setTimeout(() => this.connect(), retryInMs);
|
||||||
}
|
}
|
||||||
@@ -212,7 +224,13 @@ class RoomSocket {
|
|||||||
reconnectNow(message) {
|
reconnectNow(message) {
|
||||||
if (this.stopped || this.socket?.readyState === WebSocket.OPEN || this.socket?.readyState === WebSocket.CONNECTING) return;
|
if (this.stopped || this.socket?.readyState === WebSocket.OPEN || this.socket?.readyState === WebSocket.CONNECTING) return;
|
||||||
clearTimeout(this.reconnectTimer);
|
clearTimeout(this.reconnectTimer);
|
||||||
this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs: 0 });
|
const status = reconnectStatus(this.reconnectAttempt, navigator.onLine);
|
||||||
|
this.onStatus?.(status, {
|
||||||
|
attempt: this.reconnectAttempt,
|
||||||
|
persistent: status === "error",
|
||||||
|
message,
|
||||||
|
retryInMs: 0,
|
||||||
|
});
|
||||||
this.connect();
|
this.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user