diff --git a/Cargo.lock b/Cargo.lock
index 5dcd93d..8f53364 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
-version = "0.2.9"
+version = "0.2.10"
dependencies = [
"argon2",
"aws-config",
diff --git a/Cargo.toml b/Cargo.toml
index 7c9fd7d..dad6b37 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
-version = "0.2.9"
+version = "0.2.10"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
diff --git a/static/css/styles.css b/static/css/styles.css
index 2536085..b5a73c7 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -4861,7 +4861,7 @@ dialog::backdrop {
width: 100%;
min-height: 36px;
align-items: center;
- justify-content: flex-start;
+ justify-content: space-between;
}
.pad-page .header-actions .page-settings-menu {
@@ -5453,12 +5453,16 @@ dialog::backdrop {
min-height: 0;
}
-/* Compact Page settings dropdown. */
+/* Published page action and settings in one dropdown. */
.page-settings {
position: relative;
}
.page-settings>summary {
+ display: inline-flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 9px;
list-style: none;
cursor: pointer;
}
@@ -5467,6 +5471,35 @@ dialog::backdrop {
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 {
position: absolute;
z-index: 30;
@@ -5474,7 +5507,7 @@ dialog::backdrop {
right: 0;
display: grid;
gap: 4px;
- min-width: 220px;
+ min-width: 260px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 9px;
@@ -5482,6 +5515,46 @@ dialog::backdrop {
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,
.mobile-editor-options__panel .mobile-option-check {
min-height: 30px;
@@ -5495,11 +5568,6 @@ dialog::backdrop {
background: var(--surface-hover);
}
-#publish-page:disabled {
- opacity: .45;
- cursor: not-allowed;
-}
-
/* Keep the Simple/Full switch inside the editor label frame. */
.authorship-mode-control {
padding: 1px;
@@ -6007,7 +6075,7 @@ dialog::backdrop {
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));
background: var(--note-action-accent-bg);
color: var(--accent-text-strong);
@@ -6015,6 +6083,7 @@ dialog::backdrop {
.pad-page .header-actions>.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 {
outline: 2px solid var(--focus);
outline-offset: 2px;
diff --git a/static/editor.html b/static/editor.html
index 9f4ed32..4406001 100644
--- a/static/editor.html
+++ b/static/editor.html
@@ -7,7 +7,7 @@
__DOCUMENT_TITLE__ · __PARENT_TITLE__
__APP_THEME_BOOTSTRAP__
- __APP_STYLESHEET__
+ __APP_STYLESHEET__
__APP_IMPORT_MAP__
__APP_ENTRYPOINT__
@@ -34,17 +34,23 @@
-
+
Connection
interruptedTrying to reconnect
automatically.
@@ -199,7 +206,8 @@
- Ctrl/Cmd+ZUndoCtrl/Cmd+BBoldCtrl/Cmd+IItalicCtrl/Cmd+Shift+XStrikethroughCtrl/Cmd+KLinkCtrl/Cmd+Shift+7Numbered
+ Ctrl/Cmd+ZUndo last
+ changeCtrl/Cmd+Shift+ZRedoCtrl/Cmd+BBoldCtrl/Cmd+IItalicCtrl/Cmd+Shift+XStrikethroughCtrl/Cmd+KLinkCtrl/Cmd+Shift+7Numbered
listCtrl/Cmd+Shift+8Bullet listCtrl/Cmd+Shift+9Task
listAlt+1…4Headings H1–H4__EXTRA_SHORTCUTS__
diff --git a/static/js/note-editor.js b/static/js/note-editor.js
index 97aadde..2a01bfa 100644
--- a/static/js/note-editor.js
+++ b/static/js/note-editor.js
@@ -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 shareToken = new URLSearchParams(location.search).get("share");
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;
+ 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 singlePaneQuery = window.matchMedia("(max-width: 760px)");
let compactView = uiState.view === "preview" ? "preview" : "edit";
@@ -577,6 +649,29 @@ export function startNoteEditor(adapter) {
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) => `${escapeHtml(line) || "
"}
`).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() {
return singlePaneQuery.matches ? compactView : uiState.view;
}
@@ -621,6 +716,7 @@ export function startNoteEditor(adapter) {
if (content === editor.value) {
if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length);
previousContent = content;
+ editHistory.syncCurrent();
renderGutter();
requestAnimationFrame(revealLinkedLine);
return;
@@ -636,6 +732,7 @@ export function startNoteEditor(adapter) {
editor.scrollTop = scrollTop;
editor.scrollLeft = scrollLeft;
applyingRemote = false;
+ editHistory.reset();
render();
editor.scrollTop = scrollTop;
editor.scrollLeft = scrollLeft;
@@ -1062,6 +1159,29 @@ export function startNoteEditor(adapter) {
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);
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());
@@ -1151,12 +1271,21 @@ export function startNoteEditor(adapter) {
deletePreviewSelection();
});
const publishPageButton = document.querySelector("#publish-page");
+ const pageSettings = document.querySelector(".page-settings");
function updatePageControls() {
const enabled = publicPageEnabled.checked;
publishPageButton.disabled = !enabled;
publicTaskUpdates.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);
publicPageEnabled.addEventListener("change", async () => {
const previous = !publicPageEnabled.checked;
@@ -1211,17 +1340,16 @@ export function startNoteEditor(adapter) {
syncEditorLayers();
syncPreviewScroll();
});
- editor.addEventListener("input", () => {
+ editor.addEventListener("input", event => {
const nextContent = editor.value;
authorship = adoptCurrentOwnerAliases(authorship, previousContent.length);
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length);
authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner());
previousContent = nextContent;
render();
- if (applyingRemote) return;
- clearTimeout(saveTimer);
- document.querySelector("#save-state").textContent = "Saving…";
- saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250);
+ if (applyingRemote || applyingHistory) return;
+ editHistory.record(event.inputType || "");
+ scheduleDocumentSave();
});
passwordDialog.addEventListener("cancel", event => {
if (info?.protected && !resourceUnlocked) {