diff --git a/Cargo.toml b/Cargo.toml
index a0da890..12e9c0f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
-version = "0.2.1"
+version = "0.2.2"
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 2007e01..0f0571a 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -4537,7 +4537,8 @@ dialog::backdrop {
backdrop-filter: blur(10px);
}
- .mobile-editor-bubble>button {
+ .mobile-editor-bubble>button,
+ .mobile-editor-bubble>.mobile-color-button {
display: inline-grid;
width: 34px;
height: 34px;
@@ -4548,6 +4549,25 @@ dialog::backdrop {
background: rgba(255, 255, 255, .07);
color: var(--text);
font-size: 1rem;
+ touch-action: manipulation;
+ }
+
+ .mobile-color-button {
+ position: relative;
+ flex: 0 0 auto;
+ overflow: hidden;
+ cursor: pointer;
+ }
+
+ .mobile-color-button input[type="color"] {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ border: 0;
+ opacity: 0;
+ cursor: pointer;
}
.mobile-color-dot {
@@ -4583,7 +4603,6 @@ dialog::backdrop {
/* Movable quick actions for compact editor screens. */
@media (max-width: 1499px) {
.pad-page .mobile-editor-bubble {
- touch-action: none;
user-select: none;
}
@@ -4591,10 +4610,10 @@ dialog::backdrop {
display: none;
}
- .pad-page .room-details.is-mobile-open .room-popover {
+ .pad-page .room-popover.is-mobile-open {
position: fixed;
right: 12px;
- bottom: 72px;
+ bottom: calc(72px + env(safe-area-inset-bottom, 0px));
left: auto;
z-index: 60;
width: min(560px, calc(100vw - 24px));
diff --git a/static/js/note-editor.js b/static/js/note-editor.js
index 5f9e68f..ca70c6b 100644
--- a/static/js/note-editor.js
+++ b/static/js/note-editor.js
@@ -27,7 +27,7 @@ export function startNoteEditor(adapter) {
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread");
let unreadChat = 0;
- const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), saveEditorSettingsButton = document.querySelector("#save-editor-settings"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
+ const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), saveEditorSettingsButton = document.querySelector("#save-editor-settings"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken);
const notePreferenceKey = name => `rustpad:${name}:${adapter.access.kind}:${adapter.access.key}`;
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 = "";
@@ -66,7 +66,7 @@ export function startNoteEditor(adapter) {
return /^#[0-9a-f]{6}$/i.test(parts.color) && parts.color.toLowerCase() === color.toLowerCase();
}, replacement, contentLength);
}
- function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); const overridden = Boolean(noteUserColor()); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); currentUser.title = overridden ? "Note color override" : "Global profile color"; userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; useGlobalColorButton.hidden = !overridden; document.querySelector(".mobile-editor-bubble")?.style.setProperty("--owner", color); }
+ function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); const pickerColor = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; const overridden = Boolean(noteUserColor()); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); currentUser.title = overridden ? "Note color override" : "Global profile color"; userColorPicker.value = pickerColor; if (mobileColorPicker) mobileColorPicker.value = pickerColor; useGlobalColorButton.hidden = !overridden; document.querySelector(".mobile-editor-bubble")?.style.setProperty("--owner", color); }
function sessionHeaders() { const token = accessToken || getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
function accountHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
async function loadNoteInfo() {
@@ -632,18 +632,32 @@ export function startNoteEditor(adapter) {
});
modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-numbers", lineToggle.checked ? "on" : "off"); renderGutter(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); alignPreviewLineNumbers(preview); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); lineLinksToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-links", lineLinksToggle.checked ? "on" : "off"); renderGutter(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); });
document.querySelector("#mobile-files-button")?.addEventListener("click", () => document.querySelector("#files-button")?.click());
- document.querySelector("#mobile-color-button")?.addEventListener("click", () => userColorPicker.click());
const compactBubbleQuery = matchMedia("(max-width: 1499px)");
+ const roomPopover = roomDetails.querySelector(".room-popover");
+ function setMobileChatOpen(open) {
+ if (!roomPopover) return;
+ if (open && compactBubbleQuery.matches) {
+ roomDetails.classList.add("is-mobile-open");
+ roomPopover.classList.add("is-mobile-open");
+ document.body.append(roomPopover);
+ roomDetails.open = true;
+ clearUnread();
+ requestAnimationFrame(() => chatInput.focus());
+ return;
+ }
+ roomDetails.open = false;
+ roomDetails.classList.remove("is-mobile-open");
+ roomPopover.classList.remove("is-mobile-open");
+ if (roomPopover.parentElement !== roomDetails) roomDetails.append(roomPopover);
+ }
document.querySelector("#mobile-chat-button")?.addEventListener("click", event => {
event.stopPropagation();
- roomDetails.classList.add("is-mobile-open");
- roomDetails.open = true;
- clearUnread();
- requestAnimationFrame(() => chatInput.focus());
+ setMobileChatOpen(true);
});
document.addEventListener("click", event => {
- if (roomDetails.classList.contains("is-mobile-open") && !event.target.closest("#mobile-chat-button, #room-details")) { roomDetails.open = false; roomDetails.classList.remove("is-mobile-open"); }
+ if (roomDetails.classList.contains("is-mobile-open") && !event.target.closest("#mobile-chat-button, #room-details, .room-popover.is-mobile-open")) setMobileChatOpen(false);
});
+ compactBubbleQuery.addEventListener("change", event => { if (!event.matches && roomDetails.classList.contains("is-mobile-open")) setMobileChatOpen(false); });
const mobileBubble = document.querySelector("#mobile-editor-bubble");
const mobileBubbleDrag = document.querySelector("#mobile-bubble-drag");
const bubblePositionKey = `rustpad:mobile-bubble:${adapter.access.kind}`;
@@ -937,13 +951,16 @@ export function startNoteEditor(adapter) {
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await savePublicOptions(); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { updatePageControls(); } });
unprotectPublicPage.addEventListener("change", async () => { unprotectPublicPage.disabled = true; try { await savePublicOptions(); toast(unprotectPublicPage.checked ? "Published page is now unprotected" : "Published page protection enabled"); } catch (error) { unprotectPublicPage.checked = !unprotectPublicPage.checked; toast(error.message); } finally { updatePageControls(); } });
publishPageButton.addEventListener("click", async () => { if (!publicPageEnabled.checked) return; try { const result = await savePublicOptions(); if (!result.url) throw new Error("Page is disabled"); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } });
- roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } else { roomDetails.classList.remove("is-mobile-open"); } });
+ roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } else if (roomDetails.classList.contains("is-mobile-open")) { setMobileChatOpen(false); } });
document.addEventListener("visibilitychange", () => { if (!document.hidden && roomDetails.open) clearUnread(); });
chatForm.addEventListener("submit", event => { event.preventDefault(); const text = chatInput.value.trim(); if (!text || !socket) return; socket.chat(text); chatInput.value = ""; chatInput.focus(); });
if (!chatMessages.children.length) { const empty = document.createElement("p"); empty.className = "chat-empty"; empty.textContent = "No messages yet"; chatMessages.append(empty); }
- currentUser.addEventListener("click", () => userColorPicker.click());
- userColorPicker.addEventListener("change", async () => {
- noteColor = userColorPicker.value;
+ currentUser.addEventListener("click", () => {
+ if (typeof userColorPicker.showPicker === "function") userColorPicker.showPicker();
+ else userColorPicker.click();
+ });
+ async function saveUserColor(color) {
+ noteColor = color;
if (getAuthToken()) {
try { await adapter.saveColor(accountHeaders(), noteColor); } catch (error) { toast(error.message); await loadNoteInfo(); return; }
} else {
@@ -953,9 +970,11 @@ export function startNoteEditor(adapter) {
const replacement = currentOwner();
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
updateCurrentUser(); render();
- socket?.setColor(userColorPicker.value);
+ socket?.setColor(noteColor);
if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
- });
+ }
+ userColorPicker.addEventListener("change", () => saveUserColor(userColorPicker.value));
+ mobileColorPicker?.addEventListener("change", () => saveUserColor(mobileColorPicker.value));
useGlobalColorButton.addEventListener("click", async () => {
if (getAuthToken()) {
try { await adapter.saveColor(accountHeaders(), null); } catch (error) { toast(error.message); return; }
diff --git a/static/note.html b/static/note.html
index 165d9c3..ab841a5 100644
--- a/static/note.html
+++ b/static/note.html
@@ -244,8 +244,9 @@
-
+
⋮⋮
-
+