v0.2.91
This commit is contained in:
1 parent
4da41a1404
commit
5cb6960b04
31 files changed
+1611
-2123
No files matched your search
@@ -0,0 +1,240 @@
|
||||
#app-boot-overlay {
|
||||
--boot-bg: #0b0d11;
|
||||
--boot-panel: #151922;
|
||||
--boot-border: #343c49;
|
||||
--boot-text: #f4f6f8;
|
||||
--boot-muted: #abb5c3;
|
||||
--boot-accent: #a99ef8;
|
||||
--boot-brand-start: #f0aa74;
|
||||
--boot-brand-mid: #cf633d;
|
||||
--boot-brand-end: #8f3827;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483647;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: var(--boot-bg);
|
||||
color: var(--boot-text);
|
||||
font: 500 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif;
|
||||
text-align: center;
|
||||
opacity: 1;
|
||||
visibility: visible !important;
|
||||
transition: opacity .18s ease;
|
||||
}
|
||||
|
||||
:root[data-theme=light] #app-boot-overlay {
|
||||
--boot-bg: #f7f4ee;
|
||||
--boot-panel: #fffdf9;
|
||||
--boot-border: #cbc3b8;
|
||||
--boot-text: #29251f;
|
||||
--boot-muted: #625a50;
|
||||
--boot-accent: #6251bf;
|
||||
--boot-brand-start: #c9794e;
|
||||
--boot-brand-mid: #ae5d3c;
|
||||
--boot-brand-end: #7c3d2c;
|
||||
}
|
||||
|
||||
#app-boot-overlay.is-ready {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-card {
|
||||
width: min(100%, 390px);
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-brand {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
margin: 0 auto 25px;
|
||||
padding: 0 8px 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-wordmark {
|
||||
position: relative;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
font-size: calc(clamp(42px, 10vw, 57px));
|
||||
font-weight: 800;
|
||||
letter-spacing: -.035em;
|
||||
line-height: 1.13;
|
||||
background: linear-gradient(110deg,
|
||||
var(--live-gradient-start, var(--boot-brand-start)) 0,
|
||||
var(--live-gradient-mid, var(--boot-brand-mid)) 48%,
|
||||
var(--live-gradient-end, var(--boot-brand-end)) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: app-boot-wordmark-breathe 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-wordmark:after {
|
||||
content: "RustPad";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(100deg,
|
||||
transparent 35%, hsla(0, 0%, 100%, .7) 50%, transparent 65%);
|
||||
background-size: 250% 100%;
|
||||
background-position: 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: app-boot-wordmark-shine 3.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-brand:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 12%;
|
||||
width: 76%;
|
||||
height: 2px;
|
||||
border-radius: 50px;
|
||||
background: linear-gradient(90deg, transparent, var(--boot-accent), transparent);
|
||||
opacity: .75;
|
||||
transform: scaleX(.35);
|
||||
animation: app-boot-brand-underline 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#app-boot-overlay h2 {
|
||||
margin: 0 0 9px;
|
||||
font-size: 21px;
|
||||
font-weight: 720;
|
||||
letter-spacing: -.035em;
|
||||
color: var(--boot-text);
|
||||
}
|
||||
|
||||
#app-boot-overlay #app-boot-stage {
|
||||
margin: 0 0 23px;
|
||||
color: var(--boot-muted);
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-track {
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 100px;
|
||||
background: var(--boot-border);
|
||||
}
|
||||
|
||||
#app-boot-overlay #app-boot-progress-fill {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: var(--boot-accent);
|
||||
border-radius: inherit;
|
||||
transition: width .14s ease;
|
||||
}
|
||||
|
||||
#app-boot-overlay #app-boot-count {
|
||||
margin: 10px 0 18px;
|
||||
font-size: 12px;
|
||||
color: var(--boot-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-steps li {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--boot-border);
|
||||
border-radius: 7px;
|
||||
font-size: 10px;
|
||||
color: var(--boot-muted);
|
||||
background: var(--boot-panel);
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-steps li[data-status=done] {
|
||||
color: var(--boot-accent);
|
||||
border-color: var(--boot-accent);
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-steps li[data-status=active] {
|
||||
color: var(--boot-text);
|
||||
}
|
||||
|
||||
#app-boot-overlay #app-boot-retry {
|
||||
margin-top: 18px;
|
||||
padding: 10px 18px;
|
||||
border: 1px solid var(--boot-accent);
|
||||
border-radius: 8px;
|
||||
background: var(--boot-panel);
|
||||
color: var(--boot-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#app-boot-overlay #app-boot-retry[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes app-boot-wordmark-breathe {
|
||||
|
||||
0%,
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-boot-wordmark-shine {
|
||||
|
||||
0%,
|
||||
20% {
|
||||
background-position: 100%;
|
||||
}
|
||||
|
||||
70%,
|
||||
to {
|
||||
background-position: -100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-boot-brand-underline {
|
||||
|
||||
0%,
|
||||
to {
|
||||
transform: scaleX(.35);
|
||||
opacity: .4;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scaleX(1);
|
||||
opacity: .85;
|
||||
}
|
||||
}
|
||||
|
||||
#app-boot-overlay.has-error .app-boot-brand:after,
|
||||
#app-boot-overlay.has-error .app-boot-wordmark,
|
||||
#app-boot-overlay.has-error .app-boot-wordmark:after {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@media(prefers-reduced-motion:reduce) {
|
||||
|
||||
#app-boot-overlay,
|
||||
#app-boot-overlay #app-boot-progress-fill {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
#app-boot-overlay .app-boot-brand:after,
|
||||
#app-boot-overlay .app-boot-wordmark,
|
||||
#app-boot-overlay .app-boot-wordmark:after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
+579
-451
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,7 @@
|
||||
</head>
|
||||
|
||||
<body class="pad-page" data-resource-kind="__RESOURCE_KIND__" data-registration-enabled="__REGISTRATION_ENABLED__">
|
||||
__APP_LOADING_OVERLAY__
|
||||
<header class="app-header">
|
||||
<div class="app-header__main">
|
||||
<div class="resource-brand" aria-label="__RESOURCE_ARIA_LABEL__">
|
||||
|
||||
+40
-15
@@ -13,7 +13,9 @@
|
||||
</head>
|
||||
|
||||
<body class="home-page" data-registration-enabled="__REGISTRATION_ENABLED__">
|
||||
<header class="site-header home-header"><a class="brand home-brand" href="/">RustPad</a><div class="header-preferences" data-header-preferences></div></header>
|
||||
<header class="site-header home-header"><a class="brand home-brand" href="/">RustPad</a>
|
||||
<div class="header-preferences" data-header-preferences></div>
|
||||
</header>
|
||||
<main class="home-layout home-layout--wide">
|
||||
<section class="home-intro">
|
||||
<h1>Write. Share. Collaborate.</h1>
|
||||
@@ -73,18 +75,27 @@
|
||||
|
||||
<footer class="home-footer">
|
||||
<div class="home-footer__inner">
|
||||
<div id="footer-account-guest" class="home-footer__account">
|
||||
<button id="footer-login" class="footer-action footer-action--primary" type="button">Log in</button>
|
||||
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register</button>
|
||||
</div>
|
||||
<div id="footer-account-user" class="home-footer__account" hidden>
|
||||
<span id="footer-user-label" class="home-footer__user">
|
||||
<span class="home-footer__user-primary" data-account-primary></span>
|
||||
<small class="home-footer__user-secondary" data-account-secondary hidden></small>
|
||||
</span>
|
||||
<button id="footer-resources" class="footer-action footer-action--primary" type="button">My notes</button>
|
||||
<button id="footer-profile" class="footer-action" type="button">Profile</button>
|
||||
<button id="footer-logout" class="footer-action" type="button">Log out</button>
|
||||
<div id="footer-account-slot" class="home-footer__account-slot" data-state="loading" aria-busy="true">
|
||||
<div id="footer-account-pending" class="home-footer__account-pending" role="status">
|
||||
<span class="home-footer__loading-line" aria-hidden="true"></span>
|
||||
<span class="home-footer__loading-action" aria-hidden="true"></span>
|
||||
<span class="sr-only" data-i18n="home.account.loading">Checking your account...</span>
|
||||
</div>
|
||||
<a id="footer-account-retry" href="/" class="footer-action" data-i18n="home.account.retry" hidden>Reload
|
||||
page</a>
|
||||
<div id="footer-account-guest" class="home-footer__account" hidden>
|
||||
<button id="footer-login" class="footer-action footer-action--primary" type="button">Log in</button>
|
||||
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register</button>
|
||||
</div>
|
||||
<div id="footer-account-user" class="home-footer__account" hidden>
|
||||
<span id="footer-user-label" class="home-footer__user">
|
||||
<span class="home-footer__user-primary" data-account-primary></span>
|
||||
<small class="home-footer__user-secondary" data-account-secondary hidden></small>
|
||||
</span>
|
||||
<button id="footer-resources" class="footer-action footer-action--primary" type="button">My notes</button>
|
||||
<button id="footer-profile" class="footer-action" type="button">Profile</button>
|
||||
<button id="footer-logout" class="footer-action" type="button">Log out</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="footer-about" class="footer-action home-footer__about" type="button">About</button>
|
||||
</div>
|
||||
@@ -107,7 +118,8 @@
|
||||
<p>Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl</p>
|
||||
<p>This software is available under GNU GPLv3, which also permits commercial use provided that its terms are
|
||||
followed.</p>
|
||||
<p><strong>COMMERCIAL LICENSING NOTICE:</strong> If you want to distribute this software or a derivative work as
|
||||
<p><strong>COMMERCIAL LICENSING NOTICE:</strong> If you want to distribute this software or a derivative work
|
||||
as
|
||||
part of a proprietary (closed-source) product without the obligations imposed by GPLv3, contact us about a
|
||||
separate commercial license. <br>Contact: linuxiarz<at>linuxiarz.pl</p>
|
||||
</div>
|
||||
@@ -161,7 +173,8 @@
|
||||
<section class="resources-category resources-category--favorites">
|
||||
<header class="resources-category__header">
|
||||
<h3>Favorites</h3>
|
||||
<p>Favorite notes are shown only while your current account, share link, or password access can still open them. If access is lost, they are hidden and return automatically when access is restored.</p>
|
||||
<p>Favorite notes are shown only while your current account, share link, or password access can still open
|
||||
them. If access is lost, they are hidden and return automatically when access is restored.</p>
|
||||
</header>
|
||||
<div id="favorites-list" class="resources-list resources-list--favorites"></div>
|
||||
</section>
|
||||
@@ -239,6 +252,18 @@
|
||||
<p id="profile-message" class="form-message" role="status"></p>
|
||||
</form>
|
||||
</dialog>
|
||||
<script>
|
||||
// If initial account scripts/network never finish, offer a local retry rather
|
||||
// than leaving the footer placeholder indefinitely (without blocking home).
|
||||
setTimeout(() => {
|
||||
const slot = document.getElementById("footer-account-slot");
|
||||
if (slot?.dataset.state !== "loading") return;
|
||||
slot.dataset.state = "error";
|
||||
slot.setAttribute("aria-busy", "false");
|
||||
const retry = document.getElementById("footer-account-retry");
|
||||
if (retry) retry.hidden = false;
|
||||
}, 15000);
|
||||
</script>
|
||||
<div id="toast" class="toast-region" aria-live="polite" aria-label="Notifications" aria-relevant="additions removals">
|
||||
</div>
|
||||
|
||||
|
||||
+68
-7
@@ -60,7 +60,61 @@ export function parseAuthorship(content, raw) {
|
||||
}
|
||||
|
||||
export function serializeAuthorship(model, contentLength) {
|
||||
return JSON.stringify({ version: VERSION, spans: normalize(model?.spans, contentLength) });
|
||||
const canonical = canonicalSpans(model, contentLength);
|
||||
const spans = canonical
|
||||
? canonical.map(span => ({ start: span.start, end: span.end, owner: span.owner }))
|
||||
: normalize(model?.spans, contentLength);
|
||||
return JSON.stringify({ version: VERSION, spans });
|
||||
}
|
||||
|
||||
function canonicalSpans(model, length) {
|
||||
const spans = model?.spans;
|
||||
if (!Array.isArray(spans)) return null;
|
||||
let previousEnd = 0, previousOwner = "";
|
||||
for (const span of spans) {
|
||||
if (!span || typeof span.owner !== "string" || !span.owner
|
||||
|| !Number.isFinite(span.start) || !Number.isFinite(span.end)
|
||||
|| span.start < previousEnd || span.end <= span.start || span.end > length
|
||||
|| (span.start === previousEnd && span.owner === previousOwner)) return null;
|
||||
previousEnd = span.end;
|
||||
previousOwner = span.owner;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
function appendCanonicalSpan(result, start, end, owner) {
|
||||
if (!owner || end <= start) return;
|
||||
const previous = result.at(-1);
|
||||
if (previous?.owner === owner && start <= previous.end) {
|
||||
previous.end = Math.max(previous.end, end);
|
||||
} else result.push({ start, end, owner });
|
||||
}
|
||||
|
||||
function fastAuthorshipEdit(spans, prefix, oldSuffix, insertedLength, delta, owner) {
|
||||
const result = [];
|
||||
let inserted = false;
|
||||
const insert = () => {
|
||||
if (!inserted) {
|
||||
appendCanonicalSpan(result, prefix, prefix + insertedLength, owner ? String(owner) : "");
|
||||
inserted = true;
|
||||
}
|
||||
};
|
||||
for (const span of spans) {
|
||||
if (span.end <= prefix) {
|
||||
appendCanonicalSpan(result, span.start, span.end, span.owner);
|
||||
} else if (span.start >= oldSuffix) {
|
||||
insert();
|
||||
appendCanonicalSpan(result, span.start + delta, span.end + delta, span.owner);
|
||||
} else {
|
||||
if (span.start < prefix) appendCanonicalSpan(result, span.start, prefix, span.owner);
|
||||
insert();
|
||||
if (span.end > oldSuffix) {
|
||||
appendCanonicalSpan(result, prefix + insertedLength, span.end + delta, span.owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
insert();
|
||||
return { version: VERSION, spans: result };
|
||||
}
|
||||
|
||||
export function applyAuthorshipEdit(model, previousText, nextText, owner) {
|
||||
@@ -79,6 +133,8 @@ export function applyAuthorshipEdit(model, previousText, nextText, owner) {
|
||||
const removedLength = oldSuffix - prefix;
|
||||
const insertedLength = newSuffix - prefix;
|
||||
const delta = insertedLength - removedLength;
|
||||
const canonical = canonicalSpans(model, previousText.length);
|
||||
if (canonical) return fastAuthorshipEdit(canonical, prefix, oldSuffix, insertedLength, delta, owner);
|
||||
const updated = [];
|
||||
|
||||
for (const source of normalize(model?.spans, previousText.length)) {
|
||||
@@ -99,13 +155,22 @@ export function applyAuthorshipEdit(model, previousText, nextText, owner) {
|
||||
}
|
||||
|
||||
export function replaceAuthorshipOwner(model, matcher, replacement, contentLength) {
|
||||
const canonical = canonicalSpans(model, contentLength);
|
||||
if (canonical) {
|
||||
const spans = [];
|
||||
for (const span of canonical) {
|
||||
const owner = matcher(span.owner) ? String(replacement || "") : span.owner;
|
||||
appendCanonicalSpan(spans, span.start, span.end, owner);
|
||||
}
|
||||
return { version: VERSION, spans };
|
||||
}
|
||||
|
||||
return {
|
||||
version: VERSION,
|
||||
spans: normalize((model?.spans || []).map(span => matcher(span.owner) ? { ...span, owner: replacement } : span), contentLength),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function authorshipOwners(model) {
|
||||
const owners = new Map();
|
||||
for (const span of model?.spans || []) {
|
||||
@@ -120,13 +185,11 @@ export function lineAuthors(content, model) {
|
||||
const starts = [0];
|
||||
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
|
||||
|
||||
// Parsed authorship spans are sorted and non-overlapping. Retain the old
|
||||
// result for non-canonical callers instead of silently changing semantics.
|
||||
let canonical = true;
|
||||
for (let i = 0, previousEnd = 0; i < spans.length; i++) {
|
||||
const span = spans[i];
|
||||
if (!Number.isFinite(span?.start) || !Number.isFinite(span?.end)
|
||||
|| span.start < previousEnd || span.end < span.start) {
|
||||
|| span.start < previousEnd || span.end < span.start) {
|
||||
canonical = false;
|
||||
break;
|
||||
}
|
||||
@@ -187,8 +250,6 @@ export function syncAuthorshipLayer(layer, editor) {
|
||||
const canvas = layer.querySelector(".authorship-canvas");
|
||||
if (!canvas) return;
|
||||
canvas.style.transform = `translate3d(${-editor.scrollLeft}px, ${-editor.scrollTop}px, 0)`;
|
||||
// The canvas already mirrors the textarea scroll through its transform.
|
||||
// Keeping the overlay element itself at zero prevents a double offset.
|
||||
layer.scrollTop = 0;
|
||||
layer.scrollLeft = 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
(() => {
|
||||
const order = ["assets", "language", "libraries", "access", "files"];
|
||||
const resourceParts = new Set();
|
||||
const locale = String(document.documentElement.lang || "en").toLowerCase().startsWith("pl") ? "pl" : "en";
|
||||
const strings = {
|
||||
en: {
|
||||
assets: "Preparing the interface", language: "Setting up language",
|
||||
libraries: "Starting the editor", access: "Checking access", files: "Opening the note",
|
||||
complete: "Ready", error: "The note could not be opened", retry: "Reload page",
|
||||
errorDetails: "Check your connection and try again.", progress: "{done} of {total} stages completed",
|
||||
timeout: "Still waiting for the note. Check the connection or reload."
|
||||
},
|
||||
pl: {
|
||||
assets: "Przygotowanie interfejsu", language: "Ustawianie j\u0119zyka",
|
||||
libraries: "Uruchamianie edytora", access: "Sprawdzanie dost\u0119pu", files: "Otwieranie notatki",
|
||||
complete: "Gotowe", error: "Nie uda\u0142o si\u0119 otworzy\u0107 notatki", retry: "Za\u0142aduj ponownie",
|
||||
errorDetails: "Sprawd\u017a po\u0142\u0105czenie i spr\u00f3buj ponownie.", progress: "Uko\u0144czono {done} z {total} etap\u00f3w",
|
||||
timeout: "Nadal oczekujemy na notatk\u0119. Sprawd\u017a po\u0142\u0105czenie lub od\u015bwie\u017c stron\u0119."
|
||||
}
|
||||
}[locale];
|
||||
const completed = new Set();
|
||||
const loadedStyleElements = new WeakSet();
|
||||
let loadedStyleCount = 0;
|
||||
let readyRequested = false;
|
||||
let dismissed = false;
|
||||
let failure = "";
|
||||
const element = id => document.getElementById(id);
|
||||
|
||||
function update() {
|
||||
const overlay = element("app-boot-overlay");
|
||||
if (!overlay || dismissed) return;
|
||||
const count = completed.size;
|
||||
const progress = Math.round((count / order.length) * 100);
|
||||
const next = order.find(stage => !completed.has(stage));
|
||||
const heading = element("app-boot-heading");
|
||||
if (heading) heading.textContent = locale === "pl" ? "Ładowanie…" : "Loading…";
|
||||
const stageText = element("app-boot-stage");
|
||||
const stageCount = element("app-boot-count");
|
||||
const bar = element("app-boot-progress");
|
||||
const fill = element("app-boot-progress-fill");
|
||||
const retry = element("app-boot-retry");
|
||||
if (stageText) stageText.textContent = failure ? strings.error : (next ? strings[next] : strings.complete);
|
||||
if (stageCount) stageCount.textContent = failure || strings.progress.replace("{done}", count).replace("{total}", order.length);
|
||||
if (bar) bar.setAttribute("aria-valuenow", String(progress));
|
||||
if (fill) fill.style.width = `${progress}%`;
|
||||
if (retry) { retry.hidden = !failure; retry.textContent = strings.retry; }
|
||||
overlay.classList.toggle("has-error", Boolean(failure));
|
||||
for (const stage of order) {
|
||||
const node = element(`app-boot-step-${stage}`);
|
||||
if (!node) continue;
|
||||
const isDone = completed.has(stage);
|
||||
node.dataset.status = isDone ? "done" : (stage === next && !failure ? "active" : "waiting");
|
||||
node.querySelector("span")?.replaceChildren(document.createTextNode(strings[stage]));
|
||||
}
|
||||
if (readyRequested && count === order.length && !failure) dismiss();
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
const overlay = element("app-boot-overlay");
|
||||
if (!overlay) return;
|
||||
overlay.classList.add("is-ready");
|
||||
const remove = () => overlay.remove();
|
||||
overlay.addEventListener("transitionend", remove, { once: true });
|
||||
setTimeout(remove, 300);
|
||||
}
|
||||
|
||||
function complete(stage) {
|
||||
if (dismissed || failure) return;
|
||||
if (stage === "css" || stage === "js") {
|
||||
resourceParts.add(stage);
|
||||
if (resourceParts.has("css") && resourceParts.has("js")) completed.add("assets");
|
||||
} else if (order.includes(stage)) {
|
||||
completed.add(stage);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
function fail(reason) {
|
||||
if (dismissed || failure) return;
|
||||
const safeMessage = reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "";
|
||||
failure = safeMessage && safeMessage.length < 180 ? `${strings.error}: ${safeMessage}` : strings.errorDetails;
|
||||
update();
|
||||
}
|
||||
|
||||
document.addEventListener("load", event => {
|
||||
if (!(event.target instanceof HTMLLinkElement) || !event.target.matches("link[data-rustpad-shell-css]")) return;
|
||||
if (loadedStyleElements.has(event.target)) return;
|
||||
loadedStyleElements.add(event.target);
|
||||
loadedStyleCount += 1;
|
||||
const expected = document.querySelectorAll("link[data-rustpad-shell-css]").length;
|
||||
if (expected > 0 && loadedStyleCount >= expected) complete("css");
|
||||
}, true);
|
||||
document.addEventListener("error", event => {
|
||||
if (event.target instanceof HTMLLinkElement && event.target.matches("link[data-rustpad-shell-css]")) {
|
||||
fail(strings.errorDetails);
|
||||
}
|
||||
}, true);
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
element("app-boot-retry")?.addEventListener("click", () => location.reload());
|
||||
update();
|
||||
}, { once: true });
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!dismissed) fail(strings.timeout);
|
||||
}, 45000);
|
||||
|
||||
window.__RUSTPAD_LOADING__ = Object.freeze({
|
||||
complete,
|
||||
skip: complete,
|
||||
ready() { readyRequested = true; update(); if (dismissed) clearTimeout(timeout); },
|
||||
fail,
|
||||
stageDone(stage) {
|
||||
return stage === "css" || stage === "js" ? resourceParts.has(stage) : completed.has(stage);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -105,10 +105,15 @@ export class CollaborationSession {
|
||||
const replacements = mergeOwnerReplacements([], ownerReplacements);
|
||||
if (!hasTextEffect(operation) && !replacements.length) return null;
|
||||
|
||||
const localLength = this.localDocument().content.length;
|
||||
const localLength = this.buffer
|
||||
? operationTargetLength(this.buffer.operation)
|
||||
: this.outstanding
|
||||
? operationTargetLength(this.outstanding.operation)
|
||||
: this.serverContent.length;
|
||||
if (operationBaseLength(operation) !== localLength) {
|
||||
throw new Error("Local operation base length does not match the collaborative document");
|
||||
}
|
||||
const targetLength = operationTargetLength(operation);
|
||||
|
||||
if (!this.buffer) {
|
||||
this.buffer = pendingEnvelope(this.clientId, this.nextUpdateId++, operation, replacements);
|
||||
@@ -117,7 +122,7 @@ export class CollaborationSession {
|
||||
this.buffer.ownerReplacements = mergeOwnerReplacements(this.buffer.ownerReplacements, replacements);
|
||||
}
|
||||
|
||||
if (operationTargetLength(this.buffer.operation) !== this.localDocument().content.length) {
|
||||
if (operationTargetLength(this.buffer.operation) !== targetLength) {
|
||||
throw new Error("Buffered operation target length does not match the collaborative document");
|
||||
}
|
||||
return this.buffer;
|
||||
|
||||
+20
-3
@@ -100,6 +100,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even
|
||||
await handleAccountConfirmationToken() || await handleResetToken() || await handleAccountActionToken() || await handleShareInvitationToken();
|
||||
|
||||
const identityDialog = document.querySelector("#identity-dialog");
|
||||
const accountSlot = document.querySelector("#footer-account-slot");
|
||||
const guestAccount = document.querySelector("#footer-account-guest");
|
||||
const userAccount = document.querySelector("#footer-account-user");
|
||||
const userLabel = document.querySelector("#footer-user-label");
|
||||
@@ -121,6 +122,7 @@ const aboutDialog = document.querySelector("#about-dialog");
|
||||
|
||||
const profileForm = document.querySelector("#profile-form");
|
||||
let currentSession = null;
|
||||
let accountStateRevision = 0;
|
||||
|
||||
function authHeaders() { return {}; }
|
||||
function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; }
|
||||
@@ -440,6 +442,7 @@ resourcesPagination?.addEventListener("click", event => { const button = event.t
|
||||
|
||||
|
||||
function renderAccount(session) {
|
||||
accountStateRevision += 1;
|
||||
currentSession = session;
|
||||
guestAccount.hidden = Boolean(session);
|
||||
userAccount.hidden = !session;
|
||||
@@ -473,6 +476,13 @@ function renderAccount(session) {
|
||||
userLabelSecondary.hidden = true;
|
||||
}
|
||||
registerLink.hidden = !registrationEnabled;
|
||||
|
||||
if (accountSlot) {
|
||||
accountSlot.dataset.state = "ready";
|
||||
accountSlot.setAttribute("aria-busy", "false");
|
||||
const retry = document.querySelector("#footer-account-retry");
|
||||
if (retry) retry.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector("#footer-about")?.addEventListener("click", () => aboutDialog?.showModal());
|
||||
@@ -523,7 +533,7 @@ if (identityDialog) {
|
||||
profileDialog.showModal();
|
||||
});
|
||||
document.addEventListener("rustpad:languagechange", () => {
|
||||
renderAccount(currentSession);
|
||||
if (accountSlot?.dataset.state === "ready") renderAccount(currentSession);
|
||||
if (!profileDialog?.open) return;
|
||||
populateLanguageSelect(document.querySelector("#profile-language"), getLanguage());
|
||||
});
|
||||
@@ -601,6 +611,13 @@ if (identityDialog) {
|
||||
});
|
||||
|
||||
window.addEventListener("rustpad:session-expired", () => renderAccount(null));
|
||||
renderAccount(null);
|
||||
validateCurrentSession().then(renderAccount);
|
||||
const lookupRevision = accountStateRevision;
|
||||
validateCurrentSession()
|
||||
.then(session => {
|
||||
if (accountStateRevision === lookupRevision) renderAccount(session);
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn("Could not validate the saved session", error);
|
||||
if (accountStateRevision === lookupRevision) renderAccount(null);
|
||||
});
|
||||
}
|
||||
+109
-34
@@ -16,6 +16,7 @@ import { identityOperation, operationFromEdit } from "@rustpad/collaboration";
|
||||
import { CollaborationRevisionGapError, CollaborationSession } from "@rustpad/collaboration-session";
|
||||
import { copyText } from "@rustpad/clipboard";
|
||||
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
|
||||
import { buildLineStarts, createVirtualEditorLayers, VIRTUAL_LINE_THRESHOLD } from "@rustpad/virtual-editor-layers";
|
||||
import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format";
|
||||
import { createMentionAutocomplete } from "@rustpad/mention-autocomplete";
|
||||
import { createMentionNavigator } from "@rustpad/mention-navigation";
|
||||
@@ -52,6 +53,18 @@ export function startNoteEditor(adapter) {
|
||||
? crypto.randomUUID().replaceAll("-", "")
|
||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||
const collaboration = new CollaborationSession(collaborationClientId);
|
||||
const bootLoading = window.__RUSTPAD_LOADING__;
|
||||
let bootDocumentReady = false;
|
||||
let bootFilesReady = false;
|
||||
let bootFinishing = false;
|
||||
|
||||
function releaseBootForAccessDialog() {
|
||||
if (!bootLoading || bootFinishing) return;
|
||||
bootFinishing = true;
|
||||
bootLoading.complete("access");
|
||||
bootLoading.skip("files"); // Identity/password dialog is ready to use.
|
||||
bootLoading.ready();
|
||||
}
|
||||
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "", lastRevealedHeadingHash = "", flushRequested = false, accountSession = null;
|
||||
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
|
||||
let pendingPreviewViewport = null;
|
||||
@@ -549,6 +562,7 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
|
||||
function syncEditorLayers() {
|
||||
virtualLayers.sync();
|
||||
gutter.scrollTop = editor.scrollTop;
|
||||
syncAuthorshipLayer(authorshipLayer, editor);
|
||||
syncOwnerLabels();
|
||||
@@ -557,19 +571,34 @@ export function startNoteEditor(adapter) {
|
||||
let gutterLineCount = 0;
|
||||
let gutterLineHeight = 0;
|
||||
let highlightedGutterLine = null;
|
||||
let cachedLineText = null, cachedLineStarts = [0];
|
||||
const virtualLayers = createVirtualEditorLayers({ editor, gutter, ownerLabels, authorshipLayer, colorFor, ownerName });
|
||||
function updateLinkedLineHighlight() {
|
||||
const lineCount = cachedLineText === editor.value
|
||||
? cachedLineStarts.length : Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||
const linkedLine = lineFromHash(location.hash, lineCount);
|
||||
if (virtualLayers.active) virtualLayers.highlight(linkedLine);
|
||||
else {
|
||||
gutter.querySelector(`[data-line="${highlightedGutterLine}"]`)?.classList.remove("is-linked");
|
||||
gutter.querySelector(`[data-line="${linkedLine}"]`)?.classList.add("is-linked");
|
||||
}
|
||||
highlightedGutterLine = linkedLine;
|
||||
}
|
||||
function renderGutter() {
|
||||
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
|
||||
document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked);
|
||||
document.body.classList.toggle("line-links-enabled", lineLinksToggle.checked);
|
||||
gutter.setAttribute("aria-hidden", String(!lineLinksToggle.checked));
|
||||
|
||||
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||
const lines = Array.from({ length: lineCount });
|
||||
const content = editor.value;
|
||||
cachedLineText = content;
|
||||
cachedLineStarts = buildLineStarts(content);
|
||||
const lineCount = cachedLineStarts.length;
|
||||
const owners = authorshipOwners(authorship);
|
||||
renderedAuthorshipOwners = owners;
|
||||
const showAuthorship = authorshipColorsEnabled && owners.length > 0;
|
||||
const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
|
||||
const full = authorshipMode === "full";
|
||||
const authorsByLine = showAuthorship && full ? lineAuthors(content, authorship) : [];
|
||||
authorshipLayer.hidden = !showAuthorship;
|
||||
ownerLabels.hidden = !full || !showAuthorship;
|
||||
renderParticipantBadges(authorshipColorsEnabled ? owners : []);
|
||||
@@ -577,8 +606,26 @@ export function startNoteEditor(adapter) {
|
||||
editorWorkspace.dataset.authorshipMode = authorshipMode;
|
||||
const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, fontSize = parseFloat(style.fontSize) || 14, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24;
|
||||
gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`;
|
||||
// Physical rows represent sequential line numbers, not specific text lines.
|
||||
// Reuse them across character edits and add/remove only the changed tail.
|
||||
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
|
||||
ownerLabels.style.setProperty("--editor-rendered-font-size", `${fontSize}px`);
|
||||
if (lineCount >= VIRTUAL_LINE_THRESHOLD) {
|
||||
if (!virtualLayers.active) gutterLineCount = 0;
|
||||
virtualLayers.update({
|
||||
content, lineStarts: cachedLineStarts, model: authorship, authorsByLine,
|
||||
full, visible: showAuthorship, link: lineFromHash(location.hash, lineCount),
|
||||
computedStyle: style,
|
||||
});
|
||||
updateLinkedLineHighlight();
|
||||
syncEditorLayers();
|
||||
return;
|
||||
}
|
||||
if (virtualLayers.active) {
|
||||
virtualLayers.destroy();
|
||||
gutterLineCount = 0;
|
||||
gutterLineHeight = 0;
|
||||
highlightedGutterLine = null;
|
||||
}
|
||||
const lines = Array.from({ length: lineCount });
|
||||
if (gutterLineHeight !== lineHeight) {
|
||||
for (const row of gutter.children) row.style.height = `${lineHeight}px`;
|
||||
gutterLineHeight = lineHeight;
|
||||
@@ -593,8 +640,6 @@ export function startNoteEditor(adapter) {
|
||||
for (let i = gutterLineCount; i > lineCount; i--) gutter.lastElementChild?.remove();
|
||||
}
|
||||
gutterLineCount = lineCount;
|
||||
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
|
||||
ownerLabels.style.setProperty("--editor-rendered-font-size", `${fontSize}px`);
|
||||
if (full) {
|
||||
let previousAuthorSignature = null;
|
||||
ownerLabels.innerHTML = `<div class="owner-labels-canvas">${lines.map((_, i) => {
|
||||
@@ -610,12 +655,7 @@ export function startNoteEditor(adapter) {
|
||||
} else ownerLabels.replaceChildren();
|
||||
if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
|
||||
else authorshipLayer.replaceChildren();
|
||||
const linkedLine = lineFromHash(location.hash, lineCount);
|
||||
if (highlightedGutterLine !== linkedLine) {
|
||||
gutter.querySelector(`[data-line="${highlightedGutterLine}"]`)?.classList.remove("is-linked");
|
||||
gutter.querySelector(`[data-line="${linkedLine}"]`)?.classList.add("is-linked");
|
||||
highlightedGutterLine = linkedLine;
|
||||
}
|
||||
updateLinkedLineHighlight();
|
||||
syncEditorLayers();
|
||||
}
|
||||
|
||||
@@ -644,10 +684,12 @@ export function startNoteEditor(adapter) {
|
||||
|
||||
function revealLinkedLine() {
|
||||
if (!location.hash || location.hash === lastRevealedLineHash) return;
|
||||
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||
const hasCurrentIndex = cachedLineText === editor.value;
|
||||
const lineCount = hasCurrentIndex ? cachedLineStarts.length
|
||||
: Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||
const line = lineFromHash(location.hash, lineCount);
|
||||
if (!line) return;
|
||||
const offset = lineStartOffset(editor.value, line);
|
||||
const offset = hasCurrentIndex ? cachedLineStarts[line - 1] : lineStartOffset(editor.value, line);
|
||||
if (offset == null) return;
|
||||
|
||||
const style = getComputedStyle(editor);
|
||||
@@ -1135,8 +1177,6 @@ export function startNoteEditor(adapter) {
|
||||
let cachedPreviewHeight = -1;
|
||||
let cachedPreviewWidth = -1;
|
||||
function invalidatePreviewPositions() { previewPositionCache = null; }
|
||||
// The (rect.top - container.top + scrollTop) coordinates are invariant to
|
||||
// scrolling; only rebuild them when layout/content actually changes.
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
new ResizeObserver(invalidatePreviewPositions).observe(preview);
|
||||
}
|
||||
@@ -1149,10 +1189,11 @@ export function startNoteEditor(adapter) {
|
||||
preview.addEventListener("toggle", invalidatePreviewPositions, true);
|
||||
preview.addEventListener("load", invalidatePreviewPositions, true);
|
||||
window.addEventListener("resize", invalidatePreviewPositions);
|
||||
window.addEventListener("resize", () => {
|
||||
if (virtualLayers.active) virtualLayers.sync();
|
||||
});
|
||||
document.fonts?.addEventListener?.("loadingdone", invalidatePreviewPositions);
|
||||
function previewLinePositions() {
|
||||
// Height/width checks catch dynamic embeds that change geometry without
|
||||
// a DOM mutation (e.g. a media player expanding after initialization).
|
||||
const height = preview.scrollHeight, width = preview.clientWidth;
|
||||
if (previewPositionCache && cachedPreviewHeight === height && cachedPreviewWidth === width) return previewPositionCache;
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
@@ -1312,11 +1353,9 @@ export function startNoteEditor(adapter) {
|
||||
|
||||
let previewStale = true;
|
||||
let forcePreviewRender = false;
|
||||
let initialPreviewEnhancements = Promise.resolve();
|
||||
function renderNow() {
|
||||
const previewViewport = pendingPreviewViewport;
|
||||
// In editor-only view this DOM is display:none. Building a full Markdown
|
||||
// tree (and highlighting / Mermaid) on each remote edit is wasted work.
|
||||
// Keep the exceptional consumers working: heading deep-links and PDF export.
|
||||
const headingRevealPending = uiState.mode === "markdown" && location.hash
|
||||
&& !/^#L[1-9]\d*$/i.test(location.hash)
|
||||
&& lastRevealedHeadingHash !== location.hash;
|
||||
@@ -1337,9 +1376,7 @@ export function startNoteEditor(adapter) {
|
||||
scheduleAliasFileRefresh(editor.value);
|
||||
document.querySelector("#preview-label").textContent = "Preview (media / mermaid / markdown)";
|
||||
if (activeView() !== "edit" || previewNeededForPdf) {
|
||||
renderMermaid();
|
||||
renderCodeHighlight();
|
||||
renderMediaPlayers();
|
||||
initialPreviewEnhancements = Promise.allSettled([renderMermaid(), renderCodeHighlight(), renderMediaPlayers()]);
|
||||
}
|
||||
} else {
|
||||
preview.classList.add("preview--raw");
|
||||
@@ -1360,16 +1397,35 @@ export function startNoteEditor(adapter) {
|
||||
revealHeadingAnchor();
|
||||
});
|
||||
}
|
||||
async function finishInitialEditorBoot() {
|
||||
if (!bootLoading || bootFinishing || !bootDocumentReady || !bootFilesReady) return;
|
||||
bootFinishing = true;
|
||||
try {
|
||||
if (deferredRenderTimer !== null) {
|
||||
clearTimeout(deferredRenderTimer);
|
||||
deferredRenderTimer = null;
|
||||
firstDeferredRenderAt = 0;
|
||||
}
|
||||
renderNow();
|
||||
await initialPreviewEnhancements;
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
bootLoading.complete("files");
|
||||
bootLoading.ready();
|
||||
} catch (error) {
|
||||
bootLoading.fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
const render = createRenderQueue(renderNow);
|
||||
// Collaborative edits can arrive many times per second. Keep typing and
|
||||
// WebSocket processing responsive by batching expensive Markdown/DOM work.
|
||||
let deferredRenderTimer = null;
|
||||
let firstDeferredRenderAt = 0;
|
||||
let lastLocalInputAt = -Infinity;
|
||||
function scheduleDocumentRender() {
|
||||
const now = performance.now();
|
||||
if (deferredRenderTimer === null) firstDeferredRenderAt = now;
|
||||
else clearTimeout(deferredRenderTimer);
|
||||
const delay = Math.min(180, Math.max(0, 750 - (now - firstDeferredRenderAt)));
|
||||
const activeTyping = now - lastLocalInputAt < 450;
|
||||
const delay = activeTyping ? 300 : Math.min(180, Math.max(0, 750 - (now - firstDeferredRenderAt)));
|
||||
deferredRenderTimer = setTimeout(() => {
|
||||
deferredRenderTimer = null;
|
||||
firstDeferredRenderAt = 0;
|
||||
@@ -1607,7 +1663,7 @@ export function startNoteEditor(adapter) {
|
||||
throw new Error("Server snapshot does not match the collaborative operation");
|
||||
}
|
||||
if (message.owner_map != null) collaboration.serverOwnerMap = String(message.owner_map);
|
||||
if (renderView) applyCollaborativeView({ resetHistory: result.remote });
|
||||
if (renderView && !result.ownAck) applyCollaborativeView({ resetHistory: result.remote });
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1665,8 +1721,7 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const { loadFiles } = bindNoteFiles({
|
||||
const { loadFiles, initialFilesReady } = bindNoteFiles({
|
||||
editor, toast, getAccessToken: () => accessToken,
|
||||
getUploadMaxSize: () => Number(info?.upload_max_size_bytes) || 0,
|
||||
canDelete: () => Boolean(info?.can_delete_files),
|
||||
@@ -1753,6 +1808,8 @@ export function startNoteEditor(adapter) {
|
||||
applyCollaborativeView({ resetHistory: true });
|
||||
flushRequested = false;
|
||||
saveState.textContent = "Read only";
|
||||
bootDocumentReady = true;
|
||||
void finishInitialEditorBoot();
|
||||
return;
|
||||
}
|
||||
handleCollaborativeAuthentication(message);
|
||||
@@ -1760,6 +1817,8 @@ export function startNoteEditor(adapter) {
|
||||
else if (collaboration.buffer) flushCollaborativeUpdate();
|
||||
else saveState.textContent = "Changes are saved automatically";
|
||||
editor.focus();
|
||||
bootDocumentReady = true;
|
||||
void finishInitialEditorBoot();
|
||||
},
|
||||
onDocument: handleCollaborativeDocument,
|
||||
onResync: message => recoverCollaborativeSnapshot(message, "resync"),
|
||||
@@ -1780,6 +1839,7 @@ export function startNoteEditor(adapter) {
|
||||
toast.warning("A password was set for this note. Enter it to continue editing.", { title: "Password required", duration: 6500 });
|
||||
if (!passwordDialog.open) passwordDialog.showModal();
|
||||
document.querySelector("#open-password")?.focus();
|
||||
releaseBootForAccessDialog();
|
||||
try {
|
||||
await loadNoteInfo();
|
||||
adapter.configureView?.(info);
|
||||
@@ -1803,6 +1863,7 @@ export function startNoteEditor(adapter) {
|
||||
if (saveState.textContent === "Saving…") saveState.textContent = "Save failed";
|
||||
if (/nickname|session|account/i.test(message)) {
|
||||
if (!identityDialog.open) identityDialog.showModal();
|
||||
releaseBootForAccessDialog();
|
||||
return;
|
||||
}
|
||||
if (info?.protected && isResourceAccessError(message)) {
|
||||
@@ -1811,6 +1872,7 @@ export function startNoteEditor(adapter) {
|
||||
document.querySelector("#password-error").textContent = friendly;
|
||||
if (!passwordDialog.open) passwordDialog.showModal();
|
||||
document.querySelector("#open-password")?.focus();
|
||||
releaseBootForAccessDialog();
|
||||
return;
|
||||
}
|
||||
toast.danger(friendly, { title: "Editor connection error" });
|
||||
@@ -1842,23 +1904,35 @@ export function startNoteEditor(adapter) {
|
||||
nickname = accountSession?.nickname || getNickname();
|
||||
if (!nickname) {
|
||||
if (!identityDialog.open) identityDialog.showModal();
|
||||
releaseBootForAccessDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key);
|
||||
await loadNoteInfo();
|
||||
bootLoading?.complete("access");
|
||||
document.title = adapter.title(info);
|
||||
publicPageEnabled.checked = Boolean(info.public_page_enabled); publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected); updatePageControls();
|
||||
adapter.configureView?.(info);
|
||||
applyUi({ write: true, replace: true });
|
||||
updateCurrentUser();
|
||||
if (info.protected && info.access_level === "none") passwordDialog.showModal();
|
||||
else { loadFiles(); connect(); }
|
||||
if (info.protected && info.access_level === "none") {
|
||||
passwordDialog.showModal();
|
||||
releaseBootForAccessDialog();
|
||||
} else {
|
||||
void loadFiles();
|
||||
void initialFilesReady.then(() => {
|
||||
bootFilesReady = true;
|
||||
void finishInitialEditorBoot();
|
||||
});
|
||||
connect();
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.status === 403 || e.status === 404) {
|
||||
await showSystemNotFound();
|
||||
return;
|
||||
}
|
||||
bootLoading?.fail(e);
|
||||
document.body.innerHTML = `<main class="error-page"><div><h1>Page could not be loaded</h1><p>${escapeHtml(e.message)}</p></div></main>`;
|
||||
}
|
||||
}
|
||||
@@ -2159,7 +2233,7 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
}
|
||||
window.addEventListener("popstate", () => { const anchor = activeScrollAnchor(); lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; uiState = readEditorState(); applyUi(); restoreScrollAnchor(anchor); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); });
|
||||
window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; renderGutter(); if (activeView() === "edit" && uiState.mode === "markdown" && location.hash && !/^#L[1-9]\d*$/i.test(location.hash)) render(); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); });
|
||||
window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; updateLinkedLineHighlight(); if (activeView() === "edit" && uiState.mode === "markdown" && location.hash && !/^#L[1-9]\d*$/i.test(location.hash)) render(); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); });
|
||||
preview.addEventListener("click", event => {
|
||||
const link = event.target instanceof Element ? event.target.closest('a[href^="#"]') : null;
|
||||
if (!link) return;
|
||||
@@ -2626,6 +2700,7 @@ export function startNoteEditor(adapter) {
|
||||
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previous.length);
|
||||
authorship = applyAuthorshipEdit(authorship, previous, nextContent, currentOwner());
|
||||
previousContent = nextContent;
|
||||
if (!applyingRemote && !applyingHistory) lastLocalInputAt = performance.now();
|
||||
scheduleDocumentRender();
|
||||
if (applyingRemote || applyingHistory) return;
|
||||
queueCollaborativeOperation(operationFromEdit(previous, nextContent, authorship));
|
||||
|
||||
@@ -182,6 +182,8 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
||||
let currentFiles = [];
|
||||
let filesLoaded = false;
|
||||
let loadSequence = 0;
|
||||
let finishInitialFiles;
|
||||
const initialFilesReady = new Promise(resolve => { finishInitialFiles = resolve; });
|
||||
|
||||
function summaryText(files, shown = files.length) {
|
||||
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
|
||||
@@ -279,9 +281,11 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
||||
footer.textContent = tp("files.summary", currentFiles.length, { size: formatBytes(totalSize) }, `${currentFiles.length} ${currentFiles.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`);
|
||||
renderFiles();
|
||||
onFilesChanged(currentFiles);
|
||||
finishInitialFiles();
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return;
|
||||
renderLoadError(error);
|
||||
finishInitialFiles();
|
||||
if (open) notify("danger", error.message, { title: t("files.loadFailed", {}, "Could not load files") });
|
||||
}
|
||||
}
|
||||
@@ -500,5 +504,5 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
|
||||
}
|
||||
});
|
||||
|
||||
return { loadFiles };
|
||||
return { loadFiles, initialFilesReady };
|
||||
}
|
||||
+9
-1
@@ -128,7 +128,7 @@ function settleInitialPublicAnchor(hash) {
|
||||
window.addEventListener("load", scheduleCorrection, { once: true });
|
||||
cleanups.push(() => window.removeEventListener("load", scheduleCorrection));
|
||||
}
|
||||
document.fonts?.ready?.then(scheduleCorrection).catch(() => {});
|
||||
document.fonts?.ready?.then(scheduleCorrection).catch(() => { });
|
||||
|
||||
if (typeof ResizeObserver === "function") {
|
||||
observer = new ResizeObserver(scheduleCorrection);
|
||||
@@ -183,6 +183,7 @@ function restoreFullWidth() {
|
||||
async function initialize() {
|
||||
try {
|
||||
const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() });
|
||||
window.__RUSTPAD_LOADING__?.complete("access");
|
||||
if (passwordDialog.open) passwordDialog.close();
|
||||
passwordError.textContent = "";
|
||||
publicTitle.textContent = page.title;
|
||||
@@ -197,12 +198,17 @@ async function initialize() {
|
||||
installPublicPermalinks();
|
||||
alignPreviewLineNumbers(content);
|
||||
settleInitialPublicAnchor(location.hash);
|
||||
window.__RUSTPAD_LOADING__?.complete("files");
|
||||
window.__RUSTPAD_LOADING__?.ready();
|
||||
} catch (error) {
|
||||
window.__RUSTPAD_LOADING__?.complete("access");
|
||||
publicAnchorSettleCleanup?.();
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
passwordError.textContent = error.status === 403 ? t("public.passwordAuthorized", {}, "Sign in with an authorized account or enter the resource password.") : t("public.passwordCorrect", {}, "Enter the correct password.");
|
||||
if (!passwordDialog.open) passwordDialog.showModal();
|
||||
passwordInput.focus();
|
||||
window.__RUSTPAD_LOADING__?.skip("files");
|
||||
window.__RUSTPAD_LOADING__?.ready();
|
||||
return;
|
||||
}
|
||||
content.replaceChildren();
|
||||
@@ -211,6 +217,8 @@ async function initialize() {
|
||||
message.textContent = String(error.message);
|
||||
content.append(message);
|
||||
toast.danger(error.message, { title: "Could not load published page" });
|
||||
window.__RUSTPAD_LOADING__?.skip("files");
|
||||
window.__RUSTPAD_LOADING__?.ready();
|
||||
}
|
||||
}
|
||||
content.addEventListener("click", async event => {
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Virtualize only decorative editor layers. The textarea, OT document,
|
||||
* preview renderer, selection, history, and line link URLs are unchanged.
|
||||
*/
|
||||
|
||||
export const VIRTUAL_LINE_THRESHOLD = 1500;
|
||||
const OVERSCAN = 8;
|
||||
|
||||
export function buildLineStarts(text) {
|
||||
const starts = [0];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 10) starts.push(i + 1);
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
export function visibleLineWindow(scrollTop, viewportHeight, paddingTop, lineHeight, total, overscan = OVERSCAN) {
|
||||
const height = Number.isFinite(viewportHeight) ? Math.max(0, viewportHeight) : 0;
|
||||
const top = Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0;
|
||||
const rowHeight = Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : 20;
|
||||
const offset = Number.isFinite(paddingTop) ? paddingTop : 0;
|
||||
const from = Math.max(0, Math.floor((top - offset) / rowHeight));
|
||||
const to = Math.max(from + 1, Math.ceil((top + height - offset) / rowHeight));
|
||||
return {
|
||||
first: Math.max(0, Math.min(total, from - overscan)),
|
||||
end: Math.max(0, Math.min(total, to + overscan)),
|
||||
};
|
||||
}
|
||||
|
||||
export function labelGroupLines(authorsByLine, ownerName) {
|
||||
const groups = [];
|
||||
let previousSignature = null;
|
||||
for (let i = 0; i < authorsByLine.length; i++) {
|
||||
const authors = authorsByLine[i] || [];
|
||||
if (!authors.length) continue;
|
||||
const signature = authors.map(ownerName).sort((a, b) => a.localeCompare(b)).join('\u0000');
|
||||
if (signature === previousSignature) continue;
|
||||
previousSignature = signature;
|
||||
groups.push({ line: i, authors });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function lowerBound(items, value, selector = item => item) {
|
||||
let left = 0, right = items.length;
|
||||
while (left < right) {
|
||||
const mid = (left + right) >>> 1;
|
||||
if (selector(items[mid]) < value) left = mid + 1;
|
||||
else right = mid;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
export function createVirtualEditorLayers({ editor, gutter, ownerLabels, authorshipLayer, colorFor, ownerName }) {
|
||||
let active = false;
|
||||
let starts = [0], text = '', spans = [], groups = [];
|
||||
let showAuthorship = false, showLabels = false, linkedLine = null;
|
||||
let lineHeight = 20, paddingTop = 0, paddingLeft = 0;
|
||||
let spacer, gutterWindow, labelCanvas, colorCanvas, colorViewport;
|
||||
let renderedFirst = -1, renderedEnd = -1;
|
||||
let lineNodes = new Map(), labelNodes = new Map();
|
||||
|
||||
function initialize() {
|
||||
if (active) return;
|
||||
active = true;
|
||||
spacer = document.createElement('div');
|
||||
spacer.className = 'line-gutter-virtual-spacer';
|
||||
spacer.style.position = 'relative';
|
||||
spacer.style.width = '100%';
|
||||
gutterWindow = document.createElement('div');
|
||||
gutterWindow.className = 'line-gutter-virtual-window';
|
||||
gutterWindow.style.position = 'absolute';
|
||||
gutterWindow.style.left = '0';
|
||||
gutterWindow.style.right = '0';
|
||||
spacer.append(gutterWindow);
|
||||
gutter.replaceChildren(spacer);
|
||||
labelCanvas = document.createElement('div');
|
||||
labelCanvas.className = 'owner-labels-canvas';
|
||||
ownerLabels.replaceChildren(labelCanvas);
|
||||
colorCanvas = document.createElement('div');
|
||||
colorCanvas.className = 'authorship-canvas';
|
||||
colorViewport = document.createElement('div');
|
||||
colorViewport.className = 'authorship-viewport';
|
||||
colorViewport.style.position = 'absolute';
|
||||
colorViewport.style.whiteSpace = 'pre';
|
||||
colorCanvas.append(colorViewport);
|
||||
authorshipLayer.replaceChildren(colorCanvas);
|
||||
}
|
||||
|
||||
function createLineNode() {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'line-number-row';
|
||||
const button = document.createElement('button');
|
||||
button.className = 'line-number-button';
|
||||
button.type = 'button';
|
||||
button.tabIndex = -1;
|
||||
row.append(button);
|
||||
return row;
|
||||
}
|
||||
|
||||
function updateGutter(first, end, force) {
|
||||
if (!force && first === renderedFirst && end === renderedEnd) return;
|
||||
const available = [...lineNodes.values()].filter(node => {
|
||||
const line = Number(node.firstElementChild.dataset.line);
|
||||
return line < first + 1 || line > end;
|
||||
});
|
||||
const next = new Map(), fragment = document.createDocumentFragment();
|
||||
for (let i = first; i < end; i++) {
|
||||
const line = i + 1;
|
||||
const row = lineNodes.get(line) || available.pop() || createLineNode();
|
||||
row.style.height = `${lineHeight}px`;
|
||||
const button = row.firstElementChild;
|
||||
if (button.dataset.line !== String(line)) {
|
||||
button.dataset.line = String(line);
|
||||
button.textContent = String(line);
|
||||
button.setAttribute('aria-label', `Copy link to line ${line}`);
|
||||
button.classList.remove('is-copied');
|
||||
}
|
||||
button.classList.toggle('is-linked', linkedLine === line);
|
||||
next.set(line, row);
|
||||
fragment.append(row);
|
||||
}
|
||||
gutterWindow.style.top = `${first * lineHeight}px`;
|
||||
gutterWindow.replaceChildren(fragment);
|
||||
lineNodes = next;
|
||||
}
|
||||
|
||||
function createLabelNode(group) {
|
||||
const node = document.createElement('span');
|
||||
node.className = 'owner-label-group';
|
||||
for (const owner of group.authors) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'owner-label';
|
||||
badge.style.setProperty('--owner', colorFor(owner));
|
||||
badge.textContent = ownerName(owner);
|
||||
node.append(badge);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function updateLabels(first, end, force) {
|
||||
if (!showLabels) {
|
||||
if (labelNodes.size) labelCanvas.replaceChildren();
|
||||
labelNodes.clear();
|
||||
return;
|
||||
}
|
||||
if (!force && first === renderedFirst && end === renderedEnd) return;
|
||||
const next = new Map(), fragment = document.createDocumentFragment();
|
||||
for (let i = lowerBound(groups, first, item => item.line); i < groups.length && groups[i].line < end; i++) {
|
||||
const group = groups[i];
|
||||
const node = labelNodes.get(group.line) || createLabelNode(group);
|
||||
node.style.top = `${paddingTop + group.line * lineHeight + lineHeight / 2}px`;
|
||||
next.set(group.line, node);
|
||||
fragment.append(node);
|
||||
}
|
||||
labelCanvas.replaceChildren(fragment);
|
||||
labelNodes = next;
|
||||
}
|
||||
|
||||
function updateColors(first, end, force) {
|
||||
if (!showAuthorship) {
|
||||
if (colorViewport.hasChildNodes()) colorViewport.replaceChildren();
|
||||
return;
|
||||
}
|
||||
if (!force && first === renderedFirst && end === renderedEnd) return;
|
||||
const from = starts[first] ?? text.length;
|
||||
const to = end < starts.length ? starts[end] : text.length;
|
||||
const fragment = document.createDocumentFragment();
|
||||
let cursor = from;
|
||||
let index = lowerBound(spans, from + 1, item => item.end);
|
||||
while (index < spans.length) {
|
||||
const span = spans[index++];
|
||||
if (span.start >= to) break;
|
||||
const start = Math.max(cursor, span.start, from);
|
||||
const stop = Math.min(span.end, to);
|
||||
if (start > cursor) fragment.append(document.createTextNode(text.slice(cursor, start)));
|
||||
if (stop <= start) continue;
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'authorship-fragment';
|
||||
mark.style.setProperty('--owner', colorFor(span.owner));
|
||||
mark.textContent = text.slice(start, stop);
|
||||
mark.title = String(span.owner).split('\u001f', 1)[0];
|
||||
fragment.append(mark);
|
||||
cursor = stop;
|
||||
}
|
||||
if (cursor < to) fragment.append(document.createTextNode(text.slice(cursor, to)));
|
||||
if (end === starts.length && !text.endsWith('\n')) fragment.append(document.createTextNode('\n'));
|
||||
colorViewport.style.top = `${paddingTop + first * lineHeight}px`;
|
||||
colorViewport.style.left = `${paddingLeft}px`;
|
||||
colorViewport.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function updateViewport(force = false) {
|
||||
if (!active) return;
|
||||
const { first, end } = visibleLineWindow(
|
||||
editor.scrollTop, editor.clientHeight, paddingTop, lineHeight, starts.length,
|
||||
);
|
||||
if (!force && first === renderedFirst && end === renderedEnd) return;
|
||||
updateGutter(first, end, force);
|
||||
updateLabels(first, end, force);
|
||||
updateColors(first, end, force);
|
||||
renderedFirst = first;
|
||||
renderedEnd = end;
|
||||
}
|
||||
|
||||
return {
|
||||
get active() { return active; },
|
||||
update({ content, lineStarts, model, authorsByLine, full, visible, link, computedStyle }) {
|
||||
initialize();
|
||||
text = content;
|
||||
starts = lineStarts;
|
||||
spans = model?.spans || [];
|
||||
showAuthorship = visible;
|
||||
showLabels = visible && full;
|
||||
linkedLine = link;
|
||||
const style = computedStyle;
|
||||
lineHeight = parseFloat(style.lineHeight) || 29;
|
||||
paddingTop = parseFloat(style.paddingTop) || 24;
|
||||
paddingLeft = parseFloat(style.paddingLeft) || 0;
|
||||
spacer.style.height = `${starts.length * lineHeight}px`;
|
||||
gutterWindow.style.lineHeight = `${lineHeight}px`;
|
||||
colorCanvas.style.paddingTop = style.paddingTop;
|
||||
colorCanvas.style.paddingRight = style.paddingRight;
|
||||
colorCanvas.style.paddingBottom = style.paddingBottom;
|
||||
colorCanvas.style.paddingLeft = style.paddingLeft;
|
||||
colorCanvas.style.fontFamily = style.fontFamily;
|
||||
colorCanvas.style.fontSize = style.fontSize;
|
||||
colorCanvas.style.fontWeight = style.fontWeight;
|
||||
colorCanvas.style.lineHeight = style.lineHeight;
|
||||
colorCanvas.style.letterSpacing = style.letterSpacing;
|
||||
colorCanvas.style.minWidth = `${editor.scrollWidth}px`;
|
||||
colorCanvas.style.minHeight = `${editor.scrollHeight}px`;
|
||||
authorshipLayer.style.left = `${editor.offsetLeft}px`;
|
||||
groups = showLabels ? labelGroupLines(authorsByLine, ownerName) : [];
|
||||
labelNodes = new Map();
|
||||
updateViewport(true);
|
||||
},
|
||||
sync() { updateViewport(); },
|
||||
highlight(line) {
|
||||
linkedLine = line;
|
||||
for (const [number, row] of lineNodes) {
|
||||
row.firstElementChild.classList.toggle('is-linked', number === line);
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
gutter.replaceChildren();
|
||||
ownerLabels.replaceChildren();
|
||||
authorshipLayer.replaceChildren();
|
||||
lineNodes = new Map();
|
||||
labelNodes = new Map();
|
||||
renderedFirst = renderedEnd = -1;
|
||||
groups = [];
|
||||
spans = [];
|
||||
text = '';
|
||||
},
|
||||
get range() { return { first: renderedFirst, end: renderedEnd }; },
|
||||
};
|
||||
}
|
||||
+47
-11
@@ -489,7 +489,17 @@ workspaceSettingsForm.addEventListener("submit", async event => {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal());
|
||||
function updateNoteProtectionExplanation() {
|
||||
const message = document.querySelector("#note-archive-explanation");
|
||||
if (!message) return;
|
||||
message.textContent = info?.archive_deleted_notes
|
||||
? t("workspace.noteProtection.archiveEnabled", {}, "Archiving is enabled: authorized deletion moves the note, its history and files to Archived notes. The workspace owner can restore it.")
|
||||
: t("workspace.noteProtection.archiveDisabled", {}, "Archiving is disabled: authorized deletion permanently removes the note and its files. Enable archiving in Workspace settings if recovery is important.");
|
||||
}
|
||||
document.querySelector("#new-note-button").addEventListener("click", () => {
|
||||
updateNoteProtectionExplanation();
|
||||
document.querySelector("#note-dialog").showModal();
|
||||
});
|
||||
document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close());
|
||||
document.querySelector("#note-form").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
@@ -542,21 +552,47 @@ document.querySelector("#copy-workspace-link").addEventListener("click", async (
|
||||
catch (e) { toast.danger(e.message, { title: "Could not copy workspace link" }); }
|
||||
});
|
||||
async function startAuthorizedWorkspace() {
|
||||
const session = await validateCurrentSession();
|
||||
accountSession = session;
|
||||
nickname = session?.nickname || getNickname();
|
||||
const loading = window.__RUSTPAD_LOADING__;
|
||||
document.querySelector("#workspace-startup-error")?.setAttribute("hidden", "");
|
||||
try {
|
||||
const session = await validateCurrentSession();
|
||||
accountSession = session;
|
||||
nickname = session?.nickname || getNickname();
|
||||
|
||||
if (!nickname) {
|
||||
if (!nickname) {
|
||||
loading?.complete("access");
|
||||
workspaceContent.hidden = true;
|
||||
if (!identityDialog.open) identityDialog.showModal();
|
||||
loading?.skip("files");
|
||||
loading?.ready();
|
||||
return;
|
||||
}
|
||||
|
||||
accessToken = shareToken || getAccessToken("workspace", slug);
|
||||
workspaceContent.hidden = false;
|
||||
await init();
|
||||
loading?.complete("access");
|
||||
if (workspaceLockedForPassword) loading?.skip("files");
|
||||
else loading?.complete("files");
|
||||
loading?.ready();
|
||||
} catch (error) {
|
||||
loading?.fail(error);
|
||||
console.error("Workspace initialization failed", error);
|
||||
workspaceContent.hidden = true;
|
||||
if (!identityDialog.open) identityDialog.showModal();
|
||||
return;
|
||||
const panel = document.querySelector("#workspace-startup-error");
|
||||
const message = document.querySelector("#workspace-startup-message");
|
||||
if (message) message.textContent = `${t("workspace.connectionError", {}, "Workspace connection error")}: ${error?.message || "Please retry."}`;
|
||||
if (panel) panel.hidden = false;
|
||||
}
|
||||
|
||||
accessToken = shareToken || getAccessToken("workspace", slug);
|
||||
workspaceContent.hidden = false;
|
||||
await init();
|
||||
}
|
||||
|
||||
document.querySelector("#workspace-startup-retry")?.addEventListener("click", async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
try { await startAuthorizedWorkspace(); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
|
||||
bindIdentityDialog({
|
||||
dialog: identityDialog,
|
||||
onIdentity: async (value, session) => {
|
||||
|
||||
+27
-8
@@ -27,6 +27,11 @@
|
||||
</div>
|
||||
<div class="header-actions"><button id="copy-workspace-link" class="secondary-button">Copy link</button></div>
|
||||
</header>
|
||||
<section id="workspace-startup-error" class="workspace-startup-error" role="alert" hidden>
|
||||
<p id="workspace-startup-message"></p>
|
||||
<button id="workspace-startup-retry" class="secondary-button" type="button"
|
||||
data-i18n="common.retry">Retry</button>
|
||||
</section>
|
||||
<main id="workspace-content" class="workspace-page" hidden>
|
||||
<section class="workspace-top">
|
||||
<div>
|
||||
@@ -34,8 +39,10 @@
|
||||
<p>Select a note or create a new one.</p>
|
||||
</div>
|
||||
<div class="workspace-top__actions">
|
||||
<button id="archived-notes-button" class="secondary-button inline-button" type="button" data-i18n="workspace.archive.title" hidden>Archived notes</button>
|
||||
<button id="workspace-settings-button" class="secondary-button inline-button" type="button" aria-expanded="false" data-i18n="workspace.settings.title" hidden>Workspace settings</button>
|
||||
<button id="archived-notes-button" class="secondary-button inline-button" type="button"
|
||||
data-i18n="workspace.archive.title" hidden>Archived notes</button>
|
||||
<button id="workspace-settings-button" class="secondary-button inline-button" type="button"
|
||||
aria-expanded="false" data-i18n="workspace.settings.title" hidden>Workspace settings</button>
|
||||
<button id="new-note-button" class="primary-button inline-button" type="button">New note</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -50,9 +57,12 @@
|
||||
<form id="workspace-settings-form" class="workspace-settings-card" hidden>
|
||||
<div class="workspace-settings-card__copy">
|
||||
<strong data-i18n="workspace.settings.deletedNotes">Deleted notes</strong>
|
||||
<small data-i18n="workspace.settings.copy">Workspace-wide setting. Archived notes disappear from the workspace but remain in the database with their history and files.</small>
|
||||
<small data-i18n="workspace.settings.copy">Workspace-wide setting. Archived notes disappear from the
|
||||
workspace but remain in the database with their history and files.</small>
|
||||
</div>
|
||||
<label class="workspace-archive-toggle"><input id="archive-deleted-notes" type="checkbox"> <span data-i18n="workspace.settings.archiveToggle">Archive deleted notes instead of permanently deleting them</span></label>
|
||||
<label class="workspace-archive-toggle"><input id="archive-deleted-notes" type="checkbox"> <span
|
||||
data-i18n="workspace.settings.archiveToggle">Archive deleted notes instead of permanently deleting
|
||||
them</span></label>
|
||||
<button class="secondary-button" type="submit">Save</button>
|
||||
<small id="workspace-settings-status" class="form-message" role="status"></small>
|
||||
</form>
|
||||
@@ -78,7 +88,8 @@
|
||||
<div class="archived-notes-panel__header">
|
||||
<div>
|
||||
<h2 data-i18n="workspace.archive.title">Archived notes</h2>
|
||||
<p class="dialog-copy" data-i18n="workspace.archive.copy">Restore notes that were archived instead of permanently deleted.</p>
|
||||
<p class="dialog-copy" data-i18n="workspace.archive.copy">Restore notes that were archived instead
|
||||
of permanently deleted.</p>
|
||||
</div>
|
||||
<button id="close-archived-notes" class="icon-button" type="button" aria-label="Close dialog">×</button>
|
||||
</div>
|
||||
@@ -127,14 +138,22 @@
|
||||
<dialog id="note-dialog">
|
||||
<form id="note-form" class="dialog-panel">
|
||||
<h2>New note</h2><input id="note-name" maxlength="80" required placeholder="Note name"><label
|
||||
class="dialog-check"><input id="note-protect" type="checkbox"> Protect this note from
|
||||
deletion</label>
|
||||
class="dialog-check"><input id="note-protect" type="checkbox"
|
||||
aria-describedby="note-protection-explanation note-archive-explanation"><span
|
||||
data-i18n="workspace.noteProtect">Protect this note from deletion</span></label>
|
||||
<div class="note-protection-help">
|
||||
<p id="note-protection-explanation" data-i18n="workspace.noteProtection.explain">When enabled,
|
||||
collaborators can edit but cannot delete or archive the note or remove its files. The note creator
|
||||
or workspace owner can still do so. This does not require a password.</p>
|
||||
<p id="note-archive-explanation" aria-live="polite"></p>
|
||||
</div>
|
||||
<p id="note-error" class="form-message error"></p>
|
||||
<div class="dialog-actions"><button type="button" id="cancel-note"
|
||||
class="secondary-button">Cancel</button><button class="primary-button">Create</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<div id="toast" class="toast-region" aria-live="polite" aria-label="Notifications" aria-relevant="additions removals"></div>
|
||||
<div id="toast" class="toast-region" aria-live="polite" aria-label="Notifications"
|
||||
aria-relevant="additions removals"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in new issue
Block a user