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 @@
+ link
- Page settings -
+
@@ -118,7 +124,8 @@ class="active">Split
- 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) {