page style

This commit is contained in:
Mateusz Gruszczyński
2026-07-31 11:22:00 +02:00
parent f7afee886b
commit 618e5ae0fb
5 changed files with 229 additions and 24 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.2.9" version = "0.2.10"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.2.9" version = "0.2.10"
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"
+78 -9
View File
@@ -4861,7 +4861,7 @@ dialog::backdrop {
width: 100%; width: 100%;
min-height: 36px; min-height: 36px;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: space-between;
} }
.pad-page .header-actions .page-settings-menu { .pad-page .header-actions .page-settings-menu {
@@ -5453,12 +5453,16 @@ dialog::backdrop {
min-height: 0; min-height: 0;
} }
/* Compact Page settings dropdown. */ /* Published page action and settings in one dropdown. */
.page-settings { .page-settings {
position: relative; position: relative;
} }
.page-settings>summary { .page-settings>summary {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 9px;
list-style: none; list-style: none;
cursor: pointer; cursor: pointer;
} }
@@ -5467,6 +5471,35 @@ dialog::backdrop {
display: none; display: none;
} }
.page-settings__label {
display: inline-flex;
align-items: center;
gap: 7px;
}
.page-settings__status {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--muted);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--muted) 18%, transparent);
}
.page-settings.is-enabled .page-settings__status {
background: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent);
}
.page-settings__chevron {
font-size: .72em;
line-height: 1;
transition: transform .16s ease;
}
.page-settings[open] .page-settings__chevron {
transform: rotate(180deg);
}
.page-settings-menu { .page-settings-menu {
position: absolute; position: absolute;
z-index: 30; z-index: 30;
@@ -5474,7 +5507,7 @@ dialog::backdrop {
right: 0; right: 0;
display: grid; display: grid;
gap: 4px; gap: 4px;
min-width: 220px; min-width: 260px;
padding: 8px; padding: 8px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 9px; border-radius: 9px;
@@ -5482,6 +5515,46 @@ dialog::backdrop {
box-shadow: 0 12px 30px var(--shadow-28); box-shadow: 0 12px 30px var(--shadow-28);
} }
.page-settings-action {
display: grid;
gap: 2px;
width: 100%;
min-height: 46px;
padding: 7px 9px;
border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--border));
border-radius: 7px;
background: var(--note-action-accent-bg);
color: var(--text);
text-align: left;
cursor: pointer;
}
.page-settings-action span {
font-weight: 700;
}
.page-settings-action small {
color: var(--muted);
font-size: .72rem;
font-weight: 500;
}
.page-settings-action:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 68%, var(--border));
background: var(--note-action-hover);
}
.page-settings-action:disabled {
opacity: .45;
cursor: not-allowed;
}
.page-settings-divider {
height: 1px;
margin: 3px 1px;
background: var(--border);
}
.page-settings-menu .public-task-toggle, .page-settings-menu .public-task-toggle,
.mobile-editor-options__panel .mobile-option-check { .mobile-editor-options__panel .mobile-option-check {
min-height: 30px; min-height: 30px;
@@ -5495,11 +5568,6 @@ dialog::backdrop {
background: var(--surface-hover); background: var(--surface-hover);
} }
#publish-page:disabled {
opacity: .45;
cursor: not-allowed;
}
/* Keep the Simple/Full switch inside the editor label frame. */ /* Keep the Simple/Full switch inside the editor label frame. */
.authorship-mode-control { .authorship-mode-control {
padding: 1px; padding: 1px;
@@ -6007,7 +6075,7 @@ dialog::backdrop {
transform: translateY(0); transform: translateY(0);
} }
.pad-page #publish-page:not(:disabled) { .pad-page .page-settings-action:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 55%, var(--note-action-border)); border-color: color-mix(in srgb, var(--accent) 55%, var(--note-action-border));
background: var(--note-action-accent-bg); background: var(--note-action-accent-bg);
color: var(--accent-text-strong); color: var(--accent-text-strong);
@@ -6015,6 +6083,7 @@ dialog::backdrop {
.pad-page .header-actions>.secondary-button:focus-visible, .pad-page .header-actions>.secondary-button:focus-visible,
.pad-page .page-settings>summary.secondary-button:focus-visible, .pad-page .page-settings>summary.secondary-button:focus-visible,
.pad-page .page-settings-action:focus-visible,
.pad-page .editor-toolbar :is(button, summary, select):focus-visible { .pad-page .editor-toolbar :is(button, summary, select):focus-visible {
outline: 2px solid var(--focus); outline: 2px solid var(--focus);
outline-offset: 2px; outline-offset: 2px;
+14 -6
View File
@@ -34,17 +34,23 @@
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span> <span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button> </button>
<div id="header-actions" class="header-actions"><button id="copy-link" class="secondary-button">Copy <div id="header-actions" class="header-actions"><button id="copy-link" class="secondary-button">Copy
link</button><button id="publish-page" class="secondary-button">Page</button> link</button>
<details class="page-settings"> <details class="page-settings">
<summary class="secondary-button">Page settings</summary> <summary class="secondary-button" aria-label="Page options"><span class="page-settings__label"><span
<div class="page-settings-menu"><label class="public-task-toggle" class="page-settings__status" aria-hidden="true"></span>Page</span><span
class="page-settings__chevron" aria-hidden="true"></span></summary>
<div class="page-settings-menu"><button id="publish-page" class="page-settings-action"
type="button"><span>Open page</span><small>Copy its link and open it in a new
tab</small></button>
<div class="page-settings-divider" role="separator"></div><label class="public-task-toggle"
title="Enable or disable the published page"><input id="public-page-enabled" title="Enable or disable the published page"><input id="public-page-enabled"
type="checkbox"> Enable Page</label><label class="public-task-toggle" type="checkbox"> Enable Page</label><label class="public-task-toggle"
title="Allow visitors to update task checkboxes on the published page"><input title="Allow visitors to update task checkboxes on the published page"><input
id="public-task-updates" type="checkbox"> Editable tasks</label><label id="public-task-updates" type="checkbox"> Editable tasks</label><label
class="public-task-toggle" class="public-task-toggle"
title="Allow the published page to open without the resource password or private access"><input title="Allow the published page to open without the resource password or private access"><input
id="unprotect-public-page" type="checkbox"> Unprotect Page</label></div> id="unprotect-public-page" type="checkbox"> Unprotect Page</label>
</div>
</details><button id="files-button" class="secondary-button">Files</button><button id="delete-note" </details><button id="files-button" class="secondary-button">Files</button><button id="delete-note"
class="secondary-button danger-button" hidden>Delete</button><button id="history-button" class="secondary-button danger-button" hidden>Delete</button><button id="history-button"
class="secondary-button">History</button> class="secondary-button">History</button>
@@ -118,7 +124,8 @@
class="active">Split</button><button data-view="preview">Preview</button></div> class="active">Split</button><button data-view="preview">Preview</button></div>
</div> </div>
<div id="connection-notice" class="connection-notice" role="status" aria-live="polite" hidden> <div id="connection-notice" class="connection-notice" role="status" aria-live="polite" hidden>
<span class="connection-notice__signal" aria-hidden="true"><span></span><span></span><span></span></span> <span class="connection-notice__signal"
aria-hidden="true"><span></span><span></span><span></span></span>
<span class="connection-notice__content"><strong id="connection-notice-title">Connection <span class="connection-notice__content"><strong id="connection-notice-title">Connection
interrupted</strong><span id="connection-notice-message">Trying to reconnect interrupted</strong><span id="connection-notice-message">Trying to reconnect
automatically.</span></span> automatically.</span></span>
@@ -199,7 +206,8 @@
</div><button id="close-shortcuts" class="icon-button" type="button">×</button> </div><button id="close-shortcuts" class="icon-button" type="button">×</button>
</div> </div>
<div class="shortcut-grid"> <div class="shortcut-grid">
<kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered <kbd>Ctrl/Cmd+Z</kbd><span>Undo last
change</span><kbd>Ctrl/Cmd+Shift+Z</kbd><span>Redo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span>__EXTRA_SHORTCUTS__ list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span>__EXTRA_SHORTCUTS__
</div> </div>
+134 -6
View File
@@ -32,8 +32,80 @@ export function startNoteEditor(adapter) {
const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle"); const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle");
const shareToken = new URLSearchParams(location.search).get("share"); const shareToken = new URLSearchParams(location.search).get("share");
const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`; const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`;
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = ""; 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 = "";
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false; let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
const editHistory = {
entries: [],
index: -1,
lastKind: "",
lastRecordedAt: 0,
snapshot() {
return {
content: editor.value,
ownerMap: serializeAuthorship(authorship, editor.value.length),
selectionStart: editor.selectionStart,
selectionEnd: editor.selectionEnd,
selectionDirection: editor.selectionDirection,
scrollTop: editor.scrollTop,
scrollLeft: editor.scrollLeft,
};
},
reset() {
this.entries = [this.snapshot()];
this.index = 0;
this.lastKind = "";
this.lastRecordedAt = 0;
},
syncCurrent() {
if (this.index < 0) {
this.reset();
return;
}
this.entries[this.index] = this.snapshot();
},
record(inputType = "") {
const snapshot = this.snapshot();
if (this.index < 0) {
this.entries = [snapshot];
this.index = 0;
return;
}
if (this.entries[this.index]?.content === snapshot.content) {
this.entries[this.index] = snapshot;
return;
}
if (this.index < this.entries.length - 1) this.entries.splice(this.index + 1);
const kind = inputType === "insertText" || inputType === "insertCompositionText"
? "typing"
: inputType === "deleteContentBackward" || inputType === "deleteContentForward"
? "deleting"
: "action";
const now = Date.now();
const merge = kind !== "action" && kind === this.lastKind && now - this.lastRecordedAt < 900 && this.index > 0;
if (merge) this.entries[this.index] = snapshot;
else {
this.entries.push(snapshot);
this.index += 1;
if (this.entries.length > 100) {
this.entries.shift();
this.index -= 1;
}
}
this.lastKind = kind;
this.lastRecordedAt = now;
},
move(offset) {
const nextIndex = this.index + offset;
if (nextIndex < 0 || nextIndex >= this.entries.length) return false;
this.index = nextIndex;
this.lastKind = "";
this.lastRecordedAt = 0;
restoreHistorySnapshot(this.entries[this.index]);
return true;
},
undo() { return this.move(-1); },
redo() { return this.move(1); },
};
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)"); const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
const singlePaneQuery = window.matchMedia("(max-width: 760px)"); const singlePaneQuery = window.matchMedia("(max-width: 760px)");
let compactView = uiState.view === "preview" ? "preview" : "edit"; let compactView = uiState.view === "preview" ? "preview" : "edit";
@@ -577,6 +649,29 @@ export function startNoteEditor(adapter) {
preview.scrollTop = ratio * previewRange; preview.scrollTop = ratio * previewRange;
} }
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); scheduleAliasFileRefresh(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); } function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); scheduleAliasFileRefresh(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); }
function scheduleDocumentSave() {
clearTimeout(saveTimer);
document.querySelector("#save-state").textContent = "Saving…";
saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250);
}
function restoreHistorySnapshot(snapshot) {
if (!snapshot) return;
const maxOffset = snapshot.content.length;
const selectionStart = Math.min(snapshot.selectionStart ?? maxOffset, maxOffset);
const selectionEnd = Math.min(snapshot.selectionEnd ?? selectionStart, maxOffset);
applyingHistory = true;
editor.value = snapshot.content;
authorship = parseAuthorship(snapshot.content, snapshot.ownerMap);
previousContent = snapshot.content;
editor.setSelectionRange(selectionStart, selectionEnd, snapshot.selectionDirection || "none");
render();
editor.scrollTop = snapshot.scrollTop || 0;
editor.scrollLeft = snapshot.scrollLeft || 0;
syncEditorLayers();
applyingHistory = false;
editor.focus({ preventScroll: true });
scheduleDocumentSave();
}
function activeView() { function activeView() {
return singlePaneQuery.matches ? compactView : uiState.view; return singlePaneQuery.matches ? compactView : uiState.view;
} }
@@ -621,6 +716,7 @@ export function startNoteEditor(adapter) {
if (content === editor.value) { if (content === editor.value) {
if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length); if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length);
previousContent = content; previousContent = content;
editHistory.syncCurrent();
renderGutter(); renderGutter();
requestAnimationFrame(revealLinkedLine); requestAnimationFrame(revealLinkedLine);
return; return;
@@ -636,6 +732,7 @@ export function startNoteEditor(adapter) {
editor.scrollTop = scrollTop; editor.scrollTop = scrollTop;
editor.scrollLeft = scrollLeft; editor.scrollLeft = scrollLeft;
applyingRemote = false; applyingRemote = false;
editHistory.reset();
render(); render();
editor.scrollTop = scrollTop; editor.scrollTop = scrollTop;
editor.scrollLeft = scrollLeft; editor.scrollLeft = scrollLeft;
@@ -1062,6 +1159,29 @@ export function startNoteEditor(adapter) {
button.closest("details")?.removeAttribute("open"); button.closest("details")?.removeAttribute("open");
}); });
}); });
function handleHistoryShortcut(event) {
const primary = event.ctrlKey || event.metaKey;
if (!primary || event.altKey) return;
const key = event.key.toLowerCase();
const undo = key === "z" && !event.shiftKey;
const redo = (key === "z" && event.shiftKey) || (key === "y" && !event.shiftKey);
if (!undo && !redo) return;
const target = event.target instanceof Element ? event.target : null;
if (target?.closest('.preview-editable[contenteditable="true"]')) return;
if (target && target !== editor && target.matches("input, textarea, select, [contenteditable='true']")) return;
if (document.querySelector("dialog[open]") && target !== editor) return;
event.preventDefault();
event.stopPropagation();
if (redo) editHistory.redo();
else editHistory.undo();
}
document.addEventListener("keydown", handleHistoryShortcut, true);
editor.addEventListener("beforeinput", event => {
if (event.inputType !== "historyUndo" && event.inputType !== "historyRedo") return;
event.preventDefault();
if (event.inputType === "historyRedo") editHistory.redo();
else editHistory.undo();
});
bindFormatShortcuts(editor); bindFormatShortcuts(editor);
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") }); 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());
@@ -1151,12 +1271,21 @@ export function startNoteEditor(adapter) {
deletePreviewSelection(); deletePreviewSelection();
}); });
const publishPageButton = document.querySelector("#publish-page"); const publishPageButton = document.querySelector("#publish-page");
const pageSettings = document.querySelector(".page-settings");
function updatePageControls() { function updatePageControls() {
const enabled = publicPageEnabled.checked; const enabled = publicPageEnabled.checked;
publishPageButton.disabled = !enabled; publishPageButton.disabled = !enabled;
publicTaskUpdates.disabled = !enabled; publicTaskUpdates.disabled = !enabled;
unprotectPublicPage.disabled = !enabled; unprotectPublicPage.disabled = !enabled;
pageSettings?.classList.toggle("is-enabled", enabled);
pageSettings?.querySelector("summary")?.setAttribute("title", enabled ? "Published page enabled" : "Published page disabled");
} }
document.addEventListener("pointerdown", event => {
if (pageSettings?.open && !event.target.closest(".page-settings")) pageSettings.open = false;
}, { passive: true });
document.addEventListener("keydown", event => {
if (event.key === "Escape" && pageSettings?.open) pageSettings.open = false;
});
const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked, publicPageEnabled.checked); const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked, publicPageEnabled.checked);
publicPageEnabled.addEventListener("change", async () => { publicPageEnabled.addEventListener("change", async () => {
const previous = !publicPageEnabled.checked; const previous = !publicPageEnabled.checked;
@@ -1211,17 +1340,16 @@ export function startNoteEditor(adapter) {
syncEditorLayers(); syncEditorLayers();
syncPreviewScroll(); syncPreviewScroll();
}); });
editor.addEventListener("input", () => { editor.addEventListener("input", event => {
const nextContent = editor.value; const nextContent = editor.value;
authorship = adoptCurrentOwnerAliases(authorship, previousContent.length); authorship = adoptCurrentOwnerAliases(authorship, previousContent.length);
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length);
authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner());
previousContent = nextContent; previousContent = nextContent;
render(); render();
if (applyingRemote) return; if (applyingRemote || applyingHistory) return;
clearTimeout(saveTimer); editHistory.record(event.inputType || "");
document.querySelector("#save-state").textContent = "Saving…"; scheduleDocumentSave();
saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250);
}); });
passwordDialog.addEventListener("cancel", event => { passwordDialog.addEventListener("cancel", event => {
if (info?.protected && !resourceUnlocked) { if (info?.protected && !resourceUnlocked) {