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) => `