From 8b8b3b82b88166eec9914ff1410d4b7813a9060c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 3 Aug 2026 12:32:55 +0200 Subject: [PATCH] fixes in styles --- Cargo.lock | 2 +- Cargo.toml | 2 +- static/css/styles.css | 180 ++++++++++++++++++++++------------ static/editor.html | 7 +- static/js/editor-format.js | 73 ++++++++++++++ static/js/markdown.js | 2 +- static/js/note-editor.js | 191 +++++++++++++++++++++++++++++++++---- tests/editor_ui_test.mjs | 139 +++++++++++++++++++++++++++ tests/header_ui_test.mjs | 29 ++++++ 9 files changed, 536 insertions(+), 89 deletions(-) create mode 100644 tests/editor_ui_test.mjs create mode 100644 tests/header_ui_test.mjs diff --git a/Cargo.lock b/Cargo.lock index b808555..3768234 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.24" +version = "0.2.27" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index e05125e..7f874b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.24" +version = "0.2.27" 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 44697df..8e6d5bf 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -152,6 +152,8 @@ --overlay-82: rgba(4, 6, 9, .82); --glass-92: rgba(13, 16, 21, .92); --glass-toolbar: rgba(17, 21, 28, .88); + --top-header-height: 54px; + --top-header-control-height: 34px; } :root[data-theme="light"] { @@ -362,7 +364,7 @@ a { .site-header { width: min(720px, calc(100% - 32px)); - min-height: 64px; + min-height: var(--top-header-height); margin: 0 auto; display: flex; align-items: center; @@ -550,12 +552,29 @@ textarea:focus { align-items: center; justify-content: space-between; gap: 24px; - min-height: 70px; - padding: 0 20px; + min-height: var(--top-header-height); + padding: 0 16px; border-bottom: 1px solid var(--border); background: var(--surface-inset); } +.pad-page .app-header { + min-height: var(--top-header-height); + padding-block: 6px; +} + +.header-divider { + height: 24px; +} + +.app-header .secondary-button, +.public-header .secondary-button, +.pad-page .header-actions>.secondary-button, +.pad-page .page-settings>summary.secondary-button { + min-height: var(--top-header-control-height); + padding-inline: 10px; +} + .app-header__main, .header-actions { display: flex; @@ -566,7 +585,6 @@ textarea:focus { .header-divider { width: 1px; - height: 30px; background: var(--border); } @@ -1169,7 +1187,7 @@ dialog::backdrop { .app-header { align-items: flex-start; min-height: auto; - padding: 12px 14px; + padding: 8px 14px; } .app-header__main, @@ -1222,6 +1240,12 @@ dialog::backdrop { display: none; } + .app-header .secondary-button, + .public-header .secondary-button, + .pad-page .page-settings>summary.secondary-button { + min-height: 36px; + } + } @media (max-width: 480px) { @@ -1244,15 +1268,6 @@ dialog::backdrop { } } -.markdown-toggle { - border: 1px solid var(--border) !important; -} - -.markdown-toggle.active { - background: var(--surface-3) !important; - color: var(--text) !important; -} - .preview--raw { white-space: pre-wrap; overflow-wrap: anywhere; @@ -1621,7 +1636,7 @@ dialog::backdrop { display: flex; align-items: center; justify-content: space-between; - min-height: 64px; + min-height: var(--top-header-height); padding: 0 max(20px, calc((100vw - 900px) / 2)); border-bottom: 1px solid var(--border); background: var(--glass-92); @@ -2880,15 +2895,20 @@ dialog::backdrop { position: relative; } -.markdown-more>summary { - display: flex; +.markdown-more>summary, +.pad-page .toolbar-action { + display: inline-flex; align-items: center; + justify-content: center; min-height: 34px; padding: 0 9px; border: 1px solid transparent; border-radius: 7px; color: var(--text-tertiary); font-size: .78rem; + font-weight: 600; + line-height: 1; + white-space: nowrap; list-style: none; cursor: pointer; } @@ -4854,6 +4874,37 @@ dialog::backdrop { } } +/* Keep the view controls visible on Full HD displays using 125% scaling. + Only the formatting group becomes horizontally scrollable. */ +@media (min-width: 1500px) and (max-width: 1699px) { + .pad-page .editor-toolbar { + flex-wrap: nowrap; + overflow: visible; + } + + .pad-page .toolbar-group { + min-width: 0; + flex: 1 1 auto; + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 2px; + scrollbar-width: thin; + } + + .pad-page .toolbar-group:has(>.emoji-picker[open]), + .pad-page .toolbar-group:has(>.markdown-more[open]) { + overflow: visible; + } + + .pad-page :is(.editor-controls, .toolbar-action, .line-toggle, .view-switch) { + flex: 0 0 auto; + } + + .pad-page .toolbar-fill { + display: none; + } +} + /* Responsive note editor -------------------------------------------------- */ @media (max-width: 1499px) { .pad-page { @@ -4869,7 +4920,7 @@ dialog::backdrop { .pad-page .app-header { align-items: stretch; gap: 10px; - padding: 10px 12px; + padding: 6px 10px; } .pad-page .app-header__main { @@ -4893,9 +4944,9 @@ dialog::backdrop { .pad-page .header-menu-toggle { display: inline-flex; - width: 42px; - min-width: 42px; - height: 42px; + width: 36px; + min-width: 36px; + height: 36px; align-items: center; justify-content: center; flex-direction: column; @@ -4994,7 +5045,7 @@ dialog::backdrop { .pad-page .editor-toolbar { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; min-height: 0; padding: 8px; @@ -5012,19 +5063,41 @@ dialog::backdrop { .pad-page .editor-controls, .pad-page .toolbar-action, .pad-page .line-toggle, - .pad-page .toolbar-fill, - .pad-page #mode-toggle { + .pad-page .toolbar-fill { display: none; } + .pad-page #mode-toggle { + display: inline-flex; + width: auto; + min-width: 0; + align-self: center; + justify-content: center; + white-space: nowrap; + } + + .pad-page .toolbar-group:has(>.emoji-picker[open]), + .pad-page .toolbar-group:has(>.markdown-more[open]) { + overflow: visible; + } + .pad-page .view-switch { position: sticky; right: 0; z-index: 2; + width: max-content; + max-width: 100%; align-self: center; + justify-self: end; + flex: 0 0 auto; background: var(--surface-inset); } + .pad-page .view-switch button { + min-width: 56px; + white-space: nowrap; + } + .pad-page textarea, .pad-page .preview, .pad-page .authorship-layer { @@ -5055,19 +5128,25 @@ dialog::backdrop { } .pad-page .editor-toolbar { - grid-template-columns: minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + } + + .pad-page #mode-toggle { + display: none; } .pad-page .view-switch { position: static; - display: grid; - width: 100%; - grid-template-columns: repeat(2, minmax(0, 1fr)); + display: inline-flex; + width: max-content; + justify-self: end; box-sizing: border-box; } .pad-page .view-switch button { - width: 100%; + width: auto; + min-width: 62px; } .pad-page .view-switch button[data-view="split"] { @@ -5159,12 +5238,21 @@ dialog::backdrop { } } -@media (min-width: 1500px) { +@media (min-width: 1921px) { .pad-page .workspace.view-split { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } } +.document-read-only .preview-editable { + cursor: default; +} + +.document-read-only .task-checkbox { + pointer-events: none; + cursor: not-allowed; +} + /* Per-note author color override */ .use-global-color { display: inline-grid; @@ -5719,38 +5807,6 @@ dialog::backdrop { line-height: 20px; } -/* Make secondary editor actions read clearly as buttons. */ -.markdown-more>summary, -.pad-page .toolbar-action { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 30px; - padding: 5px 10px; - border: 1px solid var(--border); - border-radius: 7px; - background: var(--surface-strong, var(--surface-strong-2)); - color: var(--text); - font-weight: 600; - cursor: pointer; - box-shadow: inset 0 1px 0 var(--wash-medium); -} - -.markdown-more>summary:hover, -.pad-page .toolbar-action:hover { - border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); - filter: brightness(1.08); -} - -.markdown-more>summary { - list-style: none; -} - -.markdown-more>summary::-webkit-details-marker { - display: none; -} - - /* Final UI fixes: opaque Page settings and per-note authorship controls. */ .page-settings-menu { background: var(--surface-card); diff --git a/static/editor.html b/static/editor.html index d572681..3f68c7f 100644 --- a/static/editor.html +++ b/static/editor.html @@ -122,7 +122,7 @@ Preview lines -
@@ -153,7 +153,7 @@ + wrap="off" placeholder="Start writing…" spellcheck="false" readonly>
@@ -251,7 +251,8 @@ 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__ + listAlt+1…4Headings H1–H4TabIndent by 2 + spacesShift+TabRemove indentation__EXTRA_SHORTCUTS__
diff --git a/static/js/editor-format.js b/static/js/editor-format.js index 03a840c..8cda47c 100644 --- a/static/js/editor-format.js +++ b/static/js/editor-format.js @@ -109,3 +109,76 @@ export function bindFormatShortcuts(editor) { applyFormat(editor, format); }); } + +function selectedLineRange(value, start, end) { + const blockStart = value.lastIndexOf("\n", Math.max(0, start - 1)) + 1; + let blockEnd = value.indexOf("\n", end); + if (blockEnd < 0) blockEnd = value.length; + if (end > start && value[end - 1] === "\n") blockEnd = end - 1; + return { blockStart, blockEnd }; +} + +function mapOffsetThroughEdits(offset, edits) { + let delta = 0; + for (const edit of edits) { + if (offset < edit.start) break; + const editEnd = edit.start + edit.remove; + if (offset <= editEnd) return edit.start + delta + edit.insert.length; + delta += edit.insert.length - edit.remove; + } + return offset + delta; +} + +export function applyIndentation(editor, { outdent = false, size = 2 } = {}) { + if (!editor || editor.readOnly) return false; + const indent = " ".repeat(Math.max(1, Number(size) || 2)); + const start = editor.selectionStart; + const end = editor.selectionEnd; + const direction = editor.selectionDirection || "none"; + + if (!outdent && start === end) { + editor.setRangeText(indent, start, end, "end"); + editor.dispatchEvent(new Event("input", { bubbles: true })); + return true; + } + + const value = editor.value; + const { blockStart, blockEnd } = selectedLineRange(value, start, end); + const block = value.slice(blockStart, blockEnd); + const lineStarts = [0]; + for (let index = 0; index < block.length; index++) { + if (block[index] === "\n") lineStarts.push(index + 1); + } + + const edits = lineStarts.map(relativeStart => { + const absoluteStart = blockStart + relativeStart; + if (!outdent) return { start: absoluteStart, remove: 0, insert: indent }; + const line = value.slice(absoluteStart, value.indexOf("\n", absoluteStart) < 0 ? value.length : value.indexOf("\n", absoluteStart)); + const removable = line.startsWith("\t") ? 1 : Math.min(indent.length, (line.match(/^ +/) || [""])[0].length); + return { start: absoluteStart, remove: removable, insert: "" }; + }).filter(edit => edit.remove || edit.insert); + + if (!edits.length) return false; + let replacement = block; + for (let index = edits.length - 1; index >= 0; index--) { + const edit = edits[index]; + const relativeStart = edit.start - blockStart; + replacement = `${replacement.slice(0, relativeStart)}${edit.insert}${replacement.slice(relativeStart + edit.remove)}`; + } + + const nextStart = mapOffsetThroughEdits(start, edits); + const nextEnd = mapOffsetThroughEdits(end, edits); + editor.setRangeText(replacement, blockStart, blockEnd, "start"); + editor.setSelectionRange(nextStart, nextEnd, direction); + editor.dispatchEvent(new Event("input", { bubbles: true })); + return true; +} + +export function bindIndentationShortcuts(editor, { size = 2 } = {}) { + editor.addEventListener("keydown", event => { + if (event.key !== "Tab" || event.ctrlKey || event.metaKey || event.altKey) return; + if (editor.readOnly) return; + event.preventDefault(); + applyIndentation(editor, { outdent: event.shiftKey, size }); + }); +} diff --git a/static/js/markdown.js b/static/js/markdown.js index 02a9237..68a8922 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -261,7 +261,7 @@ export function alignPreviewLineNumbers(root) { const targetLeft = parseFloat(styles.paddingLeft || "0") - 50; const rootLeft = root.getBoundingClientRect().left; root.querySelectorAll(".preview-source-line").forEach(line => { - const lineLeft = line.getBoundingClientRect().left - rootLeft; + const lineLeft = line.getBoundingClientRect().left - rootLeft + root.scrollLeft; line.style.setProperty("--preview-line-left", `${targetLeft - lineLeft}px`); }); } diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 5a3e490..84cc46b 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -13,7 +13,7 @@ installGlobalDiagnostics(); import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship, syncAuthorshipLayer } from "@rustpad/authorship"; import { copyText } from "@rustpad/clipboard"; import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links"; -import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format"; +import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format"; import { bindEmojiPicker } from "@rustpad/emoji-picker"; import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMarkdownFileAliases } from "@rustpad/markdown"; import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session"; @@ -27,12 +27,14 @@ export function startNoteEditor(adapter) { const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer"); 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"), mobileConnectionDetails = document.querySelector("#mobile-connection-details"), 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"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message"); + const saveState = document.querySelector("#save-state"); + editor.readOnly = true; 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"), 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 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, applyingHistory = 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 = "", lastServerContent = "", lastServerOwnerMap = "[]", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = ""; let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false; const editHistory = { entries: [], @@ -95,6 +97,7 @@ export function startNoteEditor(adapter) { this.lastRecordedAt = now; }, move(offset) { + if (!canEditDocument()) return false; const nextIndex = this.index + offset; if (nextIndex < 0 || nextIndex >= this.entries.length) return false; this.index = nextIndex; @@ -109,6 +112,7 @@ export function startNoteEditor(adapter) { const compactLayoutQuery = window.matchMedia("(max-width: 1499px)"); const singlePaneQuery = window.matchMedia("(max-width: 760px)"); let compactView = uiState.view === "preview" ? "preview" : "edit"; + let renderedView = singlePaneQuery.matches ? compactView : uiState.view; let refreshFilesForAliases = () => { }; let aliasRefreshTimer = 0; let lastUnresolvedAliasKey = ""; @@ -164,6 +168,16 @@ export function startNoteEditor(adapter) { const controls = document.querySelector(".authorship-controls"); if (controls) controls.title = canManage ? "Global authorship settings" : "Only the owner can change authorship settings"; } + function setDocumentReadOnly(readOnly, label = "Read only") { + editor.readOnly = readOnly; + document.body.classList.toggle("document-read-only", readOnly); + if (!readOnly) return; + clearTimeout(saveTimer); + saveState.textContent = label; + const activePreviewEdit = preview.querySelector('.preview-editable[contenteditable="true"]'); + if (activePreviewEdit) deactivatePreviewEdit(activePreviewEdit); + } + function canEditDocument() { return !editor.readOnly; } function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h * 31 + c.charCodeAt(0)) % 360; return `hsl(${h} 70% 62%)`; } function ownerParts(owner) { const raw = String(owner || ""); const split = raw.lastIndexOf("\u001f"); return split < 0 ? { name: raw, color: "" } : { name: raw.slice(0, split), color: raw.slice(split + 1) }; } function ownerName(owner) { return ownerParts(owner).name; } @@ -517,7 +531,7 @@ export function startNoteEditor(adapter) { } function activatePreviewEdit(target, offset = null) { - if (!target) return; + if (!target || !canEditDocument()) return; const active = preview.querySelector('.preview-editable[contenteditable="true"]'); if (active && active !== target) { const targetIndex = [...preview.querySelectorAll(".preview-editable")].indexOf(target); @@ -667,6 +681,7 @@ export function startNoteEditor(adapter) { } function deletePreviewSelection() { + if (!canEditDocument()) return false; const range = previewSelectionSourceRange({ expandWholeLines: true }); if (!range || range.end <= range.start) return false; editor.setRangeText("", range.start, range.end, "end"); @@ -687,21 +702,129 @@ export function startNoteEditor(adapter) { return null; } + function editorLineHeight() { + const lineHeight = parseFloat(getComputedStyle(editor).lineHeight); + return Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : 20; + } + + function scrollRatio(element) { + const range = Math.max(0, element.scrollHeight - element.clientHeight); + return range > 0 ? element.scrollTop / range : 0; + } + + function editorScrollAnchor() { + return { + sourceLine: 1 + Math.max(0, editor.scrollTop) / editorLineHeight(), + ratio: scrollRatio(editor), + }; + } + + function previewLinePositions() { + const previewRect = preview.getBoundingClientRect(); + const positions = []; + preview.querySelectorAll(".preview-source-line[data-source-line]").forEach(node => { + const sourceLine = Number(node.dataset.sourceLine); + if (!Number.isFinite(sourceLine) || sourceLine < 1 || !node.getClientRects().length) return; + const rect = node.getBoundingClientRect(); + positions.push({ + node, + sourceLine, + top: rect.top - previewRect.top + preview.scrollTop, + bottom: rect.bottom - previewRect.top + preview.scrollTop, + }); + }); + return positions; + } + + function previewScrollAnchor() { + const positions = previewLinePositions(); + if (!positions.length) return { sourceLine: null, ratio: scrollRatio(preview) }; + const paddingTop = parseFloat(getComputedStyle(preview).paddingTop) || 0; + const viewportTop = preview.scrollTop + paddingTop; + let currentIndex = positions.findIndex(position => position.bottom > viewportTop + 0.5); + if (currentIndex < 0) currentIndex = positions.length - 1; + const current = positions[currentIndex]; + const next = positions.slice(currentIndex + 1).find(position => position.sourceLine > current.sourceLine); + const progress = Math.max(0, Math.min(1, (viewportTop - current.top) / Math.max(1, current.bottom - current.top))); + const sourceLine = next + ? current.sourceLine + progress * (next.sourceLine - current.sourceLine) + : current.sourceLine; + return { sourceLine, ratio: scrollRatio(preview) }; + } + + function activeScrollAnchor(view = renderedView) { + return view === "preview" ? previewScrollAnchor() : editorScrollAnchor(); + } + + function setScrollRatio(element, ratio) { + const range = Math.max(0, element.scrollHeight - element.clientHeight); + element.scrollTop = Math.max(0, Math.min(range, ratio * range)); + } + + function scrollEditorToAnchor(anchor) { + if (Number.isFinite(anchor?.sourceLine)) { + const range = Math.max(0, editor.scrollHeight - editor.clientHeight); + editor.scrollTop = Math.max(0, Math.min(range, (anchor.sourceLine - 1) * editorLineHeight())); + } else setScrollRatio(editor, anchor?.ratio || 0); + syncEditorLayers(); + } + + function scrollPreviewToAnchor(anchor) { + const positions = previewLinePositions(); + if (!Number.isFinite(anchor?.sourceLine) || !positions.length) { + setScrollRatio(preview, anchor?.ratio || 0); + return; + } + const sourceLine = Math.max(1, anchor.sourceLine); + const exact = positions.find(position => position.sourceLine === sourceLine); + let targetTop = exact?.top; + if (!Number.isFinite(targetTop)) { + const before = [...positions].reverse().find(position => position.sourceLine <= sourceLine); + const after = positions.find(position => position.sourceLine >= sourceLine); + if (before && after && after.sourceLine > before.sourceLine) { + const progress = (sourceLine - before.sourceLine) / (after.sourceLine - before.sourceLine); + targetTop = before.top + progress * (after.top - before.top); + } else targetTop = (before || after)?.top; + } + if (!Number.isFinite(targetTop)) { + setScrollRatio(preview, anchor.ratio || 0); + return; + } + const paddingTop = parseFloat(getComputedStyle(preview).paddingTop) || 0; + const range = Math.max(0, preview.scrollHeight - preview.clientHeight); + preview.scrollTop = Math.max(0, Math.min(range, targetTop - paddingTop)); + } + + function restoreScrollAnchor(anchor) { + requestAnimationFrame(() => { + const view = activeView(); + if (view === "edit" || view === "split") scrollEditorToAnchor(anchor); + if (view === "preview" || view === "split") scrollPreviewToAnchor(anchor); + }); + } + + function applyUiPreservingScroll(options) { + const anchor = activeScrollAnchor(); + applyUi(options); + restoreScrollAnchor(anchor); + } + function syncPreviewScroll() { if (activeView() !== "split") return; - const editorRange = Math.max(0, editor.scrollHeight - editor.clientHeight); - const previewRange = Math.max(0, preview.scrollHeight - preview.clientHeight); - const ratio = editorRange > 0 ? editor.scrollTop / editorRange : 0; - preview.scrollTop = ratio * previewRange; + scrollPreviewToAnchor(editorScrollAnchor()); } 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"; } 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…"; + if (!canEditDocument()) { + saveState.textContent = "Read only"; + return; + } + saveState.textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); } function restoreHistorySnapshot(snapshot) { - if (!snapshot) return; + if (!snapshot || !canEditDocument()) return; const maxOffset = snapshot.content.length; const selectionStart = Math.min(snapshot.selectionStart ?? maxOffset, maxOffset); const selectionEnd = Math.min(snapshot.selectionEnd ?? selectionStart, maxOffset); @@ -724,6 +847,7 @@ export function startNoteEditor(adapter) { function applyUi({ write = false, replace = false } = {}) { const view = activeView(); + renderedView = view; editorWorkspace.className = `workspace view-${view} editor-workspace-font-${fontFamily.value}`; editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`); document.body.classList.toggle("compact-editor", compactToggle.checked); @@ -735,6 +859,7 @@ export function startNoteEditor(adapter) { }); const markdown = uiState.mode === "markdown"; modeToggle.classList.toggle("active", markdown); + modeToggle.setAttribute("aria-pressed", String(markdown)); modeToggle.textContent = markdown ? "Markdown" : "Text"; render(); if (write) writeEditorState(uiState, { replace }); @@ -759,6 +884,8 @@ export function startNoteEditor(adapter) { } function applyRemote(content, ownerMap) { + lastServerContent = content; + if (ownerMap != null) lastServerOwnerMap = ownerMap; if (content === editor.value) { if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length); previousContent = content; @@ -809,14 +936,15 @@ export function startNoteEditor(adapter) { resourceUnlocked = true; if (passwordDialog.open) passwordDialog.close(); const readOnly = message.access_level === "read_only"; - editor.readOnly = readOnly; + setDocumentReadOnly(readOnly); accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(message.content, message.owner_map); + saveState.textContent = readOnly ? "Read only" : "Changes are saved automatically"; if (!readOnly) editor.focus(); }, onDocument: message => { applyRemote(message.content, message.owner_map); - document.querySelector("#save-state").textContent = `${message.author ? `${message.author} · ` : ""}${new Date(message.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; + saveState.textContent = editor.readOnly ? "Read only" : `${message.author ? `${message.author} · ` : ""}${new Date(message.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, @@ -829,9 +957,13 @@ export function startNoteEditor(adapter) { if (/read-only access/i.test(message)) { toast(friendly); accessLevel.textContent = "Access: read only"; - editor.readOnly = true; + setDocumentReadOnly(true, "Read only — changes not saved"); + applyRemote(lastServerContent, lastServerOwnerMap); + queueMicrotask(connect); return; } + clearTimeout(saveTimer); + if (saveState.textContent === "Saving…") saveState.textContent = "Save failed"; if (/nickname|session|account/i.test(message)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) { @@ -886,16 +1018,19 @@ export function startNoteEditor(adapter) { } document.querySelectorAll("[data-view]").forEach(button => button.addEventListener("click", () => { + const anchor = activeScrollAnchor(); if (singlePaneQuery.matches) { compactView = button.dataset.view === "preview" ? "preview" : "edit"; applyUi(); + restoreScrollAnchor(anchor); return; } uiState = { ...uiState, view: button.dataset.view }; applyUi({ write: true }); + restoreScrollAnchor(anchor); })); - singlePaneQuery.addEventListener("change", () => applyUi()); - compactLayoutQuery.addEventListener("change", () => applyUi()); + singlePaneQuery.addEventListener("change", () => applyUiPreservingScroll()); + compactLayoutQuery.addEventListener("change", () => applyUiPreservingScroll()); const headerMenuToggle = document.querySelector("#header-menu-toggle"); const headerActions = document.querySelector("#header-actions"); const setHeaderMenuOpen = open => { @@ -928,13 +1063,13 @@ export function startNoteEditor(adapter) { document.addEventListener("keydown", event => { if (event.key === "Escape" && mobileEditorOptions?.open) mobileEditorOptions.open = false; }); - modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); + modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUiPreservingScroll({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-numbers"), lineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("preview-line-numbers"), previewLineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); alignPreviewLineNumbers(preview); scheduleEditorSettingsSave({ personal: true }); }); - compactToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("compact"), compactToggle.checked ? "on" : "off"); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); }); + compactToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("compact"), compactToggle.checked ? "on" : "off"); syncMobileEditorControls(); applyUiPreservingScroll(); scheduleEditorSettingsSave({ personal: true }); }); lineLinksToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-links"), lineLinksToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); }); - fontFamily.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-family"), fontFamily.value); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); }); - fontSize.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-size"), fontSize.value); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); }); + fontFamily.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-family"), fontFamily.value); syncMobileEditorControls(); applyUiPreservingScroll(); scheduleEditorSettingsSave({ personal: true }); }); + fontSize.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-size"), fontSize.value); syncMobileEditorControls(); applyUiPreservingScroll(); scheduleEditorSettingsSave({ personal: true }); }); const mirrorMobileControl = (mobileControl, desktopControl) => mobileControl?.addEventListener("change", () => { if (desktopControl instanceof HTMLInputElement && desktopControl.type === "checkbox") desktopControl.checked = mobileControl.checked; else desktopControl.value = mobileControl.value; @@ -1017,6 +1152,7 @@ export function startNoteEditor(adapter) { window.addEventListener("resize", () => { if (mobileBubble?.style.left) placeMobileBubble(mobileBubble.getBoundingClientRect()); }); const cancelledPreviewEdits = new WeakSet(); function commitPreviewEdit(target, { focusNextLine = false } = {}) { + if (!canEditDocument()) { render(); return; } const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const value = markdownFromPreview(target); @@ -1054,6 +1190,7 @@ export function startNoteEditor(adapter) { } } function editRawPreviewLine(target) { + if (!canEditDocument()) return; const lineIndex = Number(target.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; @@ -1070,6 +1207,7 @@ export function startNoteEditor(adapter) { } function insertPreviewLineBreak(target) { + if (!canEditDocument()) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); @@ -1162,7 +1300,7 @@ export function startNoteEditor(adapter) { } } } - window.addEventListener("popstate", () => { lastRevealedLineHash = ""; uiState = readEditorState(); applyUi(); requestAnimationFrame(revealLinkedLine); }); + window.addEventListener("popstate", () => { const anchor = activeScrollAnchor(); lastRevealedLineHash = ""; uiState = readEditorState(); applyUi(); restoreScrollAnchor(anchor); requestAnimationFrame(revealLinkedLine); }); window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; renderGutter(); requestAnimationFrame(revealLinkedLine); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); gutter.addEventListener("click", async event => { @@ -1230,6 +1368,7 @@ export function startNoteEditor(adapter) { else editHistory.undo(); }); bindFormatShortcuts(editor); + bindIndentationShortcuts(editor, { size: 2 }); 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("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); @@ -1245,6 +1384,11 @@ export function startNoteEditor(adapter) { preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; + if (!canEditDocument()) { + checkbox.checked = !checkbox.checked; + saveState.textContent = "Read only"; + return; + } const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; @@ -1368,7 +1512,7 @@ export function startNoteEditor(adapter) { authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length); updateCurrentUser(); render(); socket?.setColor(noteColor); - if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length)); + if (socket && canEditDocument()) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length)); } userColorPicker.addEventListener("change", () => saveUserColor(userColorPicker.value)); mobileColorPicker?.addEventListener("change", () => saveUserColor(mobileColorPicker.value)); @@ -1382,7 +1526,7 @@ export function startNoteEditor(adapter) { authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length); updateCurrentUser(); render(); socket?.setColor(currentUserColor() || null); - if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length)); + if (socket && canEditDocument()) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length)); toast("Global profile color restored"); }); editor.addEventListener("keydown", continueIndentation); @@ -1391,6 +1535,11 @@ export function startNoteEditor(adapter) { syncPreviewScroll(); }); editor.addEventListener("input", event => { + if (!canEditDocument() && !applyingRemote) { + applyRemote(lastServerContent, lastServerOwnerMap); + saveState.textContent = "Read only"; + return; + } const nextContent = editor.value; authorship = adoptCurrentOwnerAliases(authorship, previousContent.length); authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); diff --git a/tests/editor_ui_test.mjs b/tests/editor_ui_test.mjs new file mode 100644 index 0000000..80f2995 --- /dev/null +++ b/tests/editor_ui_test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFile } from "node:fs/promises"; + +async function importSource(path) { + const source = await readFile(new URL(path, import.meta.url), "utf8"); + return import(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`); +} + +class FakeEditor { + constructor(value, start = 0, end = start) { + this.value = value; + this.selectionStart = start; + this.selectionEnd = end; + this.selectionDirection = "none"; + this.readOnly = false; + this.inputEvents = 0; + } + + setRangeText(replacement, start, end, mode) { + this.value = `${this.value.slice(0, start)}${replacement}${this.value.slice(end)}`; + const caret = mode === "end" ? start + replacement.length : start; + this.selectionStart = caret; + this.selectionEnd = caret; + } + + setSelectionRange(start, end, direction = "none") { + this.selectionStart = start; + this.selectionEnd = end; + this.selectionDirection = direction; + } + + dispatchEvent(event) { + if (event.type === "input") this.inputEvents += 1; + return true; + } +} + +const { applyIndentation } = await importSource("../static/js/editor-format.js"); +const markdownSource = await readFile(new URL("../static/js/markdown.js", import.meta.url), "utf8"); +const alignSource = markdownSource.match(/export function alignPreviewLineNumbers\(root\) \{[\s\S]*?\n\}/)?.[0]; +assert.ok(alignSource, "alignPreviewLineNumbers source must exist"); +const alignPreviewLineNumbers = new Function(`${alignSource.replace("export ", "")}; return alignPreviewLineNumbers;`)(); + +test("Tab inserts two spaces at the caret", () => { + const editor = new FakeEditor("abcd", 2); + assert.equal(applyIndentation(editor), true); + assert.equal(editor.value, "ab cd"); + assert.deepEqual([editor.selectionStart, editor.selectionEnd], [4, 4]); + assert.equal(editor.inputEvents, 1); +}); + +test("Tab indents every selected line", () => { + const editor = new FakeEditor("alpha\nbeta\ngamma", 0, 10); + assert.equal(applyIndentation(editor), true); + assert.equal(editor.value, " alpha\n beta\ngamma"); + assert.deepEqual([editor.selectionStart, editor.selectionEnd], [2, 14]); +}); + +test("Shift+Tab removes up to two spaces from selected lines", () => { + const editor = new FakeEditor(" alpha\n beta", 2, 13); + assert.equal(applyIndentation(editor, { outdent: true }), true); + assert.equal(editor.value, "alpha\nbeta"); + assert.deepEqual([editor.selectionStart, editor.selectionEnd], [0, 10]); +}); + +test("Shift+Tab removes a leading tab", () => { + const editor = new FakeEditor("\talpha", 4); + assert.equal(applyIndentation(editor, { outdent: true }), true); + assert.equal(editor.value, "alpha"); + assert.deepEqual([editor.selectionStart, editor.selectionEnd], [3, 3]); +}); + +test("preview line-number alignment ignores horizontal scroll", () => { + const previousGetComputedStyle = globalThis.getComputedStyle; + globalThis.getComputedStyle = () => ({ paddingLeft: "62px" }); + let assigned; + const line = { + getBoundingClientRect: () => ({ left: 70 }), + style: { setProperty: (name, value) => { assigned = [name, value]; } }, + }; + const root = { + scrollLeft: 80, + getBoundingClientRect: () => ({ left: 100 }), + querySelectorAll: () => [line], + }; + try { + alignPreviewLineNumbers(root); + assert.deepEqual(assigned, ["--preview-line-left", "-38px"]); + } finally { + globalThis.getComputedStyle = previousGetComputedStyle; + } +}); + +test("compact editor layout starts below 1500px", async () => { + const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8"); + const editorSource = await readFile(new URL("../static/js/note-editor.js", import.meta.url), "utf8"); + assert.equal((css.match(/@media \(max-width: 1499px\)/g) || []).length, 4); + assert.doesNotMatch(css, /@media \(max-width: 1920px\)/); + assert.match(editorSource, /compactLayoutQuery = window\.matchMedia\("\(max-width: 1499px\)"\)/); + assert.match(editorSource, /compactBubbleQuery = matchMedia\("\(max-width: 1499px\)"\)/); +}); + +test("read-only mode blocks every document write path", async () => { + const editorHtml = await readFile(new URL("../static/editor.html", import.meta.url), "utf8"); + const editorSource = await readFile(new URL("../static/js/note-editor.js", import.meta.url), "utf8"); + assert.match(editorHtml, /