line links
This commit is contained in:
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -22,6 +22,7 @@ const MODULES: &[&str] = &[
|
||||
"emoji-picker",
|
||||
"image-upload",
|
||||
"logger",
|
||||
"line-links",
|
||||
"markdown",
|
||||
"modal",
|
||||
"note-api",
|
||||
|
||||
@@ -5299,3 +5299,40 @@ dialog::backdrop {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
/* 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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) => `<div style="height:${lineHeight}px">${i + 1}</div>`).join("");
|
||||
gutter.innerHTML = lines.map((_, i) => `<div class="line-number-row" style="height:${lineHeight}px"><button class="line-number-button" type="button" tabindex="-1" data-line="${i + 1}" aria-label="Copy link to line ${i + 1}">${i + 1}</button></div>`).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); }
|
||||
|
||||
+2
-1
@@ -108,7 +108,8 @@
|
||||
class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Editor
|
||||
lines</label><label class="line-toggle"><input id="preview-line-numbers-toggle" type="checkbox">
|
||||
Preview lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked>
|
||||
Compact</label>
|
||||
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"
|
||||
aria-pressed="true">Markdown</button>
|
||||
<div class="view-switch"><button data-view="edit">Edit</button><button data-view="split"
|
||||
|
||||
+2
-1
@@ -107,7 +107,8 @@
|
||||
class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Editor
|
||||
lines</label><label class="line-toggle"><input id="preview-line-numbers-toggle" type="checkbox">
|
||||
Preview lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked>
|
||||
Compact</label>
|
||||
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"
|
||||
aria-pressed="true">Markdown</button>
|
||||
<div class="view-switch"><button data-view="edit">Edit</button><button data-view="split"
|
||||
|
||||
Reference in New Issue
Block a user