From 4dcbffcc889740247d91c314c174cacf8f5d0f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 29 Jul 2026 14:56:13 +0200 Subject: [PATCH] line links --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/assets.rs | 1 + static/css/styles.css | 39 ++++++++++++++++++++++++- static/js/line-links.js | 40 ++++++++++++++++++++++++++ static/js/note-editor.js | 61 +++++++++++++++++++++++++++++++++++----- static/note.html | 3 +- static/pad.html | 3 +- 8 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 static/js/line-links.js diff --git a/Cargo.lock b/Cargo.lock index 8839f5f..30fbfc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.0" +version = "0.2.1" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index b27d15f..a0da890 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.0" +version = "0.2.1" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/src/assets.rs b/src/assets.rs index 924177f..51fc2d6 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -22,6 +22,7 @@ const MODULES: &[&str] = &[ "emoji-picker", "image-upload", "logger", + "line-links", "markdown", "modal", "note-api", diff --git a/static/css/styles.css b/static/css/styles.css index f00e0bb..2007e01 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -5298,4 +5298,41 @@ dialog::backdrop { left: 10px; width: auto; } -} \ No newline at end of file +} +/* Optional links to exact editor lines. */ +.line-number-button { + display: block; + width: 100%; + height: 100%; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + font: inherit; + text-align: right; + pointer-events: none; +} + +.line-links-enabled .line-number-button { + cursor: copy; + pointer-events: auto; +} + +.line-links-enabled .line-number-button:hover, +.line-links-enabled .line-number-button:focus-visible { + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--text); + outline: none; +} + +.line-number-button.is-linked { + background: color-mix(in srgb, var(--accent) 20%, transparent); + color: var(--text); + font-weight: 700; +} + +.line-number-button.is-copied { + background: color-mix(in srgb, var(--success) 18%, transparent); + color: var(--text); +} diff --git a/static/js/line-links.js b/static/js/line-links.js new file mode 100644 index 0000000..69dea6c --- /dev/null +++ b/static/js/line-links.js @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +const LINE_HASH = /^#L([1-9]\d*)$/i; + +export function lineFromHash(hash, lineCount = Number.POSITIVE_INFINITY) { + const match = LINE_HASH.exec(String(hash || "")); + if (!match) return null; + const line = Number(match[1]); + if (!Number.isSafeInteger(line) || line > lineCount) return null; + return line; +} + +export function lineStartOffset(text, line) { + const target = Number(line); + if (!Number.isSafeInteger(target) || target < 1) return null; + if (target === 1) return 0; + + let currentLine = 1; + for (let index = 0; index < text.length; index += 1) { + if (text.charCodeAt(index) !== 10) continue; + currentLine += 1; + if (currentLine === target) return index + 1; + } + return null; +} + +export function lineLink(href, line) { + const target = Number(line); + if (!Number.isSafeInteger(target) || target < 1) throw new TypeError("Invalid line number"); + const url = new URL(href); + url.hash = `L${target}`; + return url.href; +} diff --git a/static/js/note-editor.js b/static/js/note-editor.js index ebb104e..5f9e68f 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -12,6 +12,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 { bindEmojiPicker } from "@rustpad/emoji-picker"; import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown"; @@ -26,16 +27,17 @@ export function startNoteEditor(adapter) { const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog"); const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"); let unreadChat = 0; - const compactToggle = document.querySelector("#compact-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), saveEditorSettingsButton = document.querySelector("#save-editor-settings"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color"); + const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), saveEditorSettingsButton = document.querySelector("#save-editor-settings"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color"); const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken); const notePreferenceKey = name => `rustpad:${name}:${adapter.access.kind}:${adapter.access.key}`; - let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true; + let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = ""; const compactLayoutQuery = window.matchMedia("(max-width: 1499px)"); const singlePaneQuery = window.matchMedia("(max-width: 760px)"); let compactView = uiState.view === "preview" ? "preview" : "edit"; const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off"; previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on"; compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off"; + lineLinksToggle.checked = localStorage.getItem("rustpad:line-links") === "on"; fontFamily.value = localStorage.getItem("rustpad:font-family") || "mono"; fontSize.value = localStorage.getItem("rustpad:font-size") || "14"; authorshipColorsToggle.checked = authorshipColorsEnabled; @@ -125,6 +127,11 @@ export function startNoteEditor(adapter) { } function renderGutter() { + document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked); + document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked); + document.body.classList.toggle("line-links-enabled", lineLinksToggle.checked); + gutter.setAttribute("aria-hidden", String(!lineLinksToggle.checked)); + const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1); const lines = Array.from({ length: lineCount }); const owners = authorshipOwners(authorship); @@ -138,7 +145,7 @@ export function startNoteEditor(adapter) { editorWorkspace.dataset.authorshipMode = authorshipMode; const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24; gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`; - gutter.innerHTML = lines.map((_, i) => `
${i + 1}
`).join(""); + gutter.innerHTML = lines.map((_, i) => `
`).join(""); ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`); if (full) { let previousAuthorSignature = null; @@ -155,9 +162,32 @@ export function startNoteEditor(adapter) { } else ownerLabels.replaceChildren(); if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor); else authorshipLayer.replaceChildren(); + const linkedLine = lineFromHash(location.hash, lineCount); + gutter.querySelector(`[data-line="${linkedLine}"]`)?.classList.add("is-linked"); + syncEditorLayers(); + } + + function revealLinkedLine() { + if (!location.hash || location.hash === lastRevealedLineHash) return; + const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1); + const line = lineFromHash(location.hash, lineCount); + if (!line) return; + const offset = lineStartOffset(editor.value, line); + if (offset == null) return; + + const style = getComputedStyle(editor); + const lineHeight = parseFloat(style.lineHeight) || 29; + const paddingTop = parseFloat(style.paddingTop) || 24; + const lineTop = paddingTop + (line - 1) * lineHeight; + const lineBottom = lineTop + lineHeight; + if (lineTop < editor.scrollTop || lineBottom > editor.scrollTop + editor.clientHeight) { + editor.scrollTop = Math.max(0, lineTop - Math.max(lineHeight, editor.clientHeight * 0.25)); + } + editor.setSelectionRange(offset, offset); + lastRevealedLineHash = location.hash; + gutter.querySelectorAll(".line-number-button.is-linked").forEach(button => button.classList.remove("is-linked")); + gutter.querySelector(`[data-line="${line}"]`)?.classList.add("is-linked"); syncEditorLayers(); - document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked); - document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked); } function escapeHtml(v) { return String(v).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function formatDate(value) { const raw = String(value ?? "").trim(); let normalized = raw; if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T") + ":00"; else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T"); else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) normalized = normalized.replace(" ", "T") + "Z"; const date = new Date(normalized); return Number.isNaN(date.getTime()) ? raw : date.toLocaleString("pl-PL", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); } @@ -493,6 +523,7 @@ export function startNoteEditor(adapter) { if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length); previousContent = content; renderGutter(); + requestAnimationFrame(revealLinkedLine); return; } const previewSnapshot = previewEditSnapshot(); @@ -511,6 +542,7 @@ export function startNoteEditor(adapter) { editor.scrollLeft = scrollLeft; syncEditorLayers(); restorePreviewEdit(previewSnapshot); + requestAnimationFrame(revealLinkedLine); } const { loadFiles } = bindNoteFiles({ @@ -598,7 +630,7 @@ export function startNoteEditor(adapter) { compactLayoutQuery.addEventListener("change", event => { if (!event.matches) setHeaderMenuOpen(false); }); - modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-numbers", lineToggle.checked ? "on" : "off"); renderGutter(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); alignPreviewLineNumbers(preview); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); }); + modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-numbers", lineToggle.checked ? "on" : "off"); renderGutter(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); alignPreviewLineNumbers(preview); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); lineLinksToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-links", lineLinksToggle.checked ? "on" : "off"); renderGutter(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); }); document.querySelector("#mobile-files-button")?.addEventListener("click", () => document.querySelector("#files-button")?.click()); document.querySelector("#mobile-color-button")?.addEventListener("click", () => userColorPicker.click()); const compactBubbleQuery = matchMedia("(max-width: 1499px)"); @@ -762,8 +794,23 @@ export function startNoteEditor(adapter) { saveEditorSettingsButton.disabled = !info?.can_save_editor_settings; } }); - window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); + window.addEventListener("popstate", () => { lastRevealedLineHash = ""; uiState = readEditorState(); applyUi(); requestAnimationFrame(revealLinkedLine); }); + window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; renderGutter(); requestAnimationFrame(revealLinkedLine); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); + gutter.addEventListener("click", async event => { + const button = event.target.closest(".line-number-button[data-line]"); + if (!button || !lineLinksToggle.checked) return; + const line = Number(button.dataset.line); + try { + await copyText(lineLink(currentShareUrl(uiState), line)); + gutter.querySelectorAll(".line-number-button.is-copied").forEach(item => item.classList.remove("is-copied")); + button.classList.add("is-copied"); + setTimeout(() => button.classList.remove("is-copied"), 900); + toast(`Link to line ${line} copied`); + } catch (error) { + toast(error.message); + } + }); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (error) { toast(error.message); } diff --git a/static/note.html b/static/note.html index 640f4c9..165d9c3 100644 --- a/static/note.html +++ b/static/note.html @@ -108,7 +108,8 @@ class="line-toggle"> Editor lines + Compact