fixes in styles

This commit is contained in:
Mateusz Gruszczyński
2026-08-03 12:32:55 +02:00
parent 55e58c4bff
commit 8b8b3b82b8
9 changed files with 536 additions and 89 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.24"
version = "0.2.27"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -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"
+118 -62
View File
@@ -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);
+4 -3
View File
@@ -122,7 +122,7 @@
Preview lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked>
Compact</label><label class="line-toggle"><input id="line-links-toggle" type="checkbox">
Line links</label>
<div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active"
<div class="toolbar-fill"></div><button id="mode-toggle" class="toolbar-action active"
aria-pressed="true">Markdown</button>
<div class="view-switch"><button data-view="edit">Edit</button><button data-view="split"
class="active">Split</button><button data-view="preview">Preview</button></div>
@@ -153,7 +153,7 @@
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div>
<div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor"
wrap="off" placeholder="Start writing…" spellcheck="false"></textarea>
wrap="off" placeholder="Start writing…" spellcheck="false" readonly></textarea>
</div>
</div>
<div class="preview-column">
@@ -251,7 +251,8 @@
<kbd>Ctrl/Cmd+Z</kbd><span>Undo last
change</span><kbd>Ctrl/Cmd+Shift+Z</kbd><span>Redo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span>__EXTRA_SHORTCUTS__
list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span><kbd>Tab</kbd><span>Indent by 2
spaces</span><kbd>Shift+Tab</kbd><span>Remove indentation</span>__EXTRA_SHORTCUTS__
</div>
</div>
</dialog>
+73
View File
@@ -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 });
});
}
+1 -1
View File
@@ -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`);
});
}
+170 -21
View File
@@ -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) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).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);
+139
View File
@@ -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, /<textarea id="editor"[\s\S]*?readonly><\/textarea>/);
assert.match(editorSource, /function canEditDocument\(\) \{ return !editor\.readOnly; \}/);
assert.match(editorSource, /function scheduleDocumentSave\(\) \{[\s\S]*?if \(!canEditDocument\(\)\) \{[\s\S]*?saveState\.textContent = "Read only";[\s\S]*?return;/);
assert.match(editorSource, /function activatePreviewEdit\(target, offset = null\) \{\s*if \(!target \|\| !canEditDocument\(\)\) return;/);
assert.match(editorSource, /function commitPreviewEdit\(target,[\s\S]*?if \(!canEditDocument\(\)\) \{ render\(\); return; \}/);
assert.match(editorSource, /if \(socket && canEditDocument\(\)\) socket\.update/g);
assert.match(editorSource, /setDocumentReadOnly\(true, "Read only — changes not saved"\);[\s\S]*?applyRemote\(lastServerContent, lastServerOwnerMap\);[\s\S]*?queueMicrotask\(connect\);/);
});
test("narrow editor switches stay compact", async () => {
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
assert.match(css, /\.pad-page #mode-toggle \{[\s\S]*?width:\s*auto;[\s\S]*?min-width:\s*0;[\s\S]*?white-space:\s*nowrap;/);
assert.match(css, /@media \(max-width: 760px\) \{[\s\S]*?\.pad-page \.editor-toolbar \{\s*grid-template-columns:\s*minmax\(0, 1fr\) auto;/);
assert.match(css, /@media \(max-width: 760px\) \{[\s\S]*?\.pad-page \.view-switch \{[\s\S]*?display:\s*inline-flex;[\s\S]*?width:\s*max-content;[\s\S]*?justify-self:\s*end;/);
assert.doesNotMatch(css, /\.pad-page \.view-switch \{\s*position:\s*static;\s*display:\s*grid;\s*width:\s*100%;/);
});
test("Full HD scaling keeps Preview visible without compacting the whole editor", async () => {
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
assert.match(css, /@media \(min-width: 1500px\) and \(max-width: 1699px\) \{[\s\S]*?\.pad-page \.toolbar-group \{[\s\S]*?flex:\s*1 1 auto;[\s\S]*?overflow-x:\s*auto;/);
assert.match(css, /@media \(min-width: 1500px\) and \(max-width: 1699px\) \{[\s\S]*?\.pad-page \.toolbar-fill \{\s*display:\s*none;/);
assert.match(css, /@media \(min-width: 1500px\) and \(max-width: 1699px\) \{[\s\S]*?\.pad-page :is\(\.editor-controls, \.toolbar-action, \.line-toggle, \.view-switch\) \{\s*flex:\s*0 0 auto;/);
});
test("Markdown/Text uses the same toolbar action styling as More", async () => {
const html = await readFile(new URL("../static/editor.html", import.meta.url), "utf8");
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.match(html, /id="mode-toggle" class="toolbar-action active"/);
assert.doesNotMatch(css, /\.markdown-toggle/);
assert.match(css, /\.markdown-more>summary,\s*\.pad-page \.toolbar-action \{/);
assert.match(editorSource, /modeToggle\.setAttribute\("aria-pressed", String\(markdown\)\)/);
});
+29
View File
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFile } from "node:fs/promises";
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
function rule(selector) {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`));
assert.ok(match, `Missing CSS rule: ${selector}`);
return match[1];
}
test("top-level page headers share one compact height", () => {
assert.match(css, /--top-header-height:\s*54px;/);
for (const selector of [".site-header", ".app-header", ".public-header"]) {
assert.match(rule(selector), /min-height:\s*var\(--top-header-height\)/);
}
});
test("header actions use the shared compact control height", () => {
assert.match(css, /--top-header-control-height:\s*34px;/);
assert.match(css, /\.app-header \.secondary-button,[\s\S]*?min-height:\s*var\(--top-header-control-height\)/);
assert.match(css, /\.public-header \.secondary-button,[\s\S]*?min-height:\s*var\(--top-header-control-height\)/);
});
test("small screens retain taller header controls", () => {
assert.match(css, /@media \(max-width: 720px\)[\s\S]*?\.public-header \.secondary-button,[\s\S]*?min-height:\s*36px;/);
});