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 @@ - + ⋮⋮ - +