diff --git a/Cargo.lock b/Cargo.lock index 02bb6ca..2b55757 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.28" +version = "0.2.29" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 1ad7db3..b608b53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.28" +version = "0.2.29" 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 7825c31..8c70da8 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -5104,6 +5104,10 @@ dialog::backdrop { padding: 16px; } + .pad-page:not(.hide-preview-line-numbers) .preview { + padding-left: 62px; + } + .pad-page .editor-footer { align-items: center; gap: 8px; diff --git a/static/js/note-editor.js b/static/js/note-editor.js index d454e59..391a59d 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -1059,10 +1059,15 @@ export function startNoteEditor(adapter) { if (!event.matches) setHeaderMenuOpen(false); }); const mobileEditorOptions = document.querySelector("#mobile-editor-options"); + const markdownMore = document.querySelector(".markdown-more"); + const mobileToolbarQuery = matchMedia("(max-width: 720px)"); document.addEventListener("pointerdown", event => { if (mobileEditorOptions?.open && !event.target.closest("#mobile-editor-options")) { mobileEditorOptions.open = false; } + if (mobileToolbarQuery.matches && markdownMore?.open && !markdownMore.contains(event.target)) { + markdownMore.open = false; + } }, { passive: true }); document.addEventListener("keydown", event => { if (event.key === "Escape" && mobileEditorOptions?.open) mobileEditorOptions.open = false; diff --git a/static/js/note-files.js b/static/js/note-files.js index bee8e26..361e7e0 100644 --- a/static/js/note-files.js +++ b/static/js/note-files.js @@ -47,7 +47,7 @@ function safeAttachmentUrl(value) { : safePublicUrl(raw, { allowMailto: false }); } -export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, canUpload, toast, onFilesChanged = () => {} }) { +export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, canUpload, toast, onFilesChanged = () => { } }) { const dialog = document.querySelector("#files-dialog"); const list = document.querySelector("#files-list"); const input = document.querySelector("#file-input"); diff --git a/tests/editor_ui_test.mjs b/tests/editor_ui_test.mjs deleted file mode 100644 index a21f31e..0000000 --- a/tests/editor_ui_test.mjs +++ /dev/null @@ -1,159 +0,0 @@ -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, /