This commit is contained in:
Mateusz Gruszczyński
2026-07-29 11:48:44 +02:00
parent 904f59146b
commit 0655cfe48c
12 changed files with 938 additions and 191 deletions
+12 -1
View File
@@ -171,6 +171,17 @@ export function lineOwners(content, model) {
});
}
export function syncAuthorshipLayer(layer, editor) {
if (!layer || !editor) return;
const canvas = layer.querySelector(".authorship-canvas");
if (!canvas) return;
canvas.style.transform = `translate3d(${-editor.scrollLeft}px, ${-editor.scrollTop}px, 0)`;
// The canvas already mirrors the textarea scroll through its transform.
// Keeping the overlay element itself at zero prevents a double offset.
layer.scrollTop = 0;
layer.scrollLeft = 0;
}
export function renderAuthorshipLayer(layer, editor, model, colorFor) {
if (!layer) return;
const style = getComputedStyle(editor);
@@ -202,6 +213,6 @@ export function renderAuthorshipLayer(layer, editor, model, colorFor) {
}
if (cursor < text.length) canvas.append(document.createTextNode(text.slice(cursor)));
if (!text.endsWith("\n")) canvas.append(document.createTextNode("\n"));
canvas.style.transform = `translate3d(${-editor.scrollLeft}px, ${-editor.scrollTop}px, 0)`;
layer.replaceChildren(canvas);
syncAuthorshipLayer(layer, editor);
}
+12
View File
@@ -11,6 +11,18 @@ function toggleWrap(editor, before, after = before, placeholder = "tekst") {
editor.setRangeText(selected, start - before.length, end + after.length, "select");
return;
}
if (selected.includes("\n") && !before.includes("\n") && !after.includes("\n")) {
const lines = selected.split("\n");
const nonEmpty = lines.filter(line => line.length > 0);
const allWrapped = nonEmpty.length > 0 && nonEmpty.every(line => line.startsWith(before) && line.endsWith(after) && line.length >= before.length + after.length);
const replacement = lines.map(line => {
if (!line) return line;
return allWrapped ? line.slice(before.length, line.length - after.length) : `${before}${line}${after}`;
}).join("\n");
editor.setRangeText(replacement, start, end, "select");
return;
}
if (selected.startsWith(before) && selected.endsWith(after) && selected.length >= before.length + after.length) {
editor.setRangeText(selected.slice(before.length, -after.length), start, end, "select");
return;
+4 -4
View File
@@ -93,7 +93,7 @@ function normalizeLanguage(value) {
return languageAliases[language] || language.replace(/[^a-z0-9_-]/g, "");
}
const attrs = (line, editable = false, prefix = "", suffix = "", lineOffset = 0) => ` class="preview-source-line${editable ? " preview-editable" : ""}" data-source-line="${line + lineOffset + 1}"${editable ? ` contenteditable="true" spellcheck="true" data-source-prefix="${escapeHtml(prefix)}" data-source-suffix="${escapeHtml(suffix)}"` : ""}`;
const attrs = (line, editable = false, prefix = "", suffix = "", lineOffset = 0) => ` class="preview-source-line${editable ? " preview-editable" : ""}" data-source-line="${line + lineOffset + 1}"${editable ? ` data-source-prefix="${escapeHtml(prefix)}" data-source-suffix="${escapeHtml(suffix)}"` : ""}`;
const isPlainText = line => !/[`*_~^=\[\]<>|:#]/.test(line) && !/^\s*(?:[-+*>]|\d+\.)\s/.test(line);
@@ -135,7 +135,7 @@ function renderList(lines, start, lineOffset = 0, baseIndent = null, forcedType
if (item.checked != null) {
body += `<input type="checkbox" class="task-checkbox" data-source-line="${sourceLine}"${item.checked ? " checked" : ""}>`;
}
body += `<span class="preview-editable list-item-content" contenteditable="true" spellcheck="true" data-source-line="${sourceLine}" data-source-prefix="${escapeHtml(prefix)}">${inline(item.text)}</span>`;
body += `<span class="preview-editable list-item-content" data-source-line="${sourceLine}" data-source-prefix="${escapeHtml(prefix)}">${inline(item.text)}</span>`;
index++;
while (index < lines.length) {
@@ -275,13 +275,13 @@ export function renderMarkdown(source, lineOffset = 0) {
closeList();
const headers = splitTableRow(line);
html += `<div class="table-wrap preview-source-line" data-source-line="${index + lineOffset + 1}"><table><thead><tr>`;
headers.forEach((cell, i) => html += `<th class="preview-editable" contenteditable="true" spellcheck="true" data-source-line="${index + lineOffset + 1}" data-table-cell="${i}" style="text-align:${delimiter[i] || "left"}">${inline(cell)}</th>`);
headers.forEach((cell, i) => html += `<th class="preview-editable" data-source-line="${index + lineOffset + 1}" data-table-cell="${i}" style="text-align:${delimiter[i] || "left"}">${inline(cell)}</th>`);
html += `</tr></thead><tbody>`;
index += 2;
while (index < lines.length && lines[index].includes("|") && lines[index].trim()) {
const cells = splitTableRow(lines[index]);
html += `<tr>`;
headers.forEach((_, i) => html += `<td class="preview-editable" contenteditable="true" spellcheck="true" data-source-line="${index + lineOffset + 1}" data-table-cell="${i}" style="text-align:${delimiter[i] || "left"}">${inline(cells[i] || "")}</td>`);
headers.forEach((_, i) => html += `<td class="preview-editable" data-source-line="${index + lineOffset + 1}" data-table-cell="${i}" style="text-align:${delimiter[i] || "left"}">${inline(cells[i] || "")}</td>`);
html += `</tr>`;
index++;
}
+425 -51
View File
@@ -1,7 +1,7 @@
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship } from "@rustpad/authorship";
import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship, syncAuthorshipLayer } from "@rustpad/authorship";
import { copyText } from "@rustpad/clipboard";
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { bindEmojiPicker } from "@rustpad/emoji-picker";
@@ -102,6 +102,18 @@ export function startNoteEditor(adapter) {
return badge;
}));
}
function syncOwnerLabels() {
ownerLabels.querySelectorAll(".owner-label-group[data-content-top]").forEach(group => {
group.style.top = `${Number(group.dataset.contentTop) - editor.scrollTop}px`;
});
}
function syncEditorLayers() {
gutter.scrollTop = editor.scrollTop;
syncAuthorshipLayer(authorshipLayer, editor);
syncOwnerLabels();
}
function renderGutter() {
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
const lines = Array.from({ length: lineCount });
@@ -123,44 +135,98 @@ export function startNoteEditor(adapter) {
ownerLabels.innerHTML = lines.map((_, i) => {
const authors = authorsByLine[i] || [];
if (!authors.length) return "";
const top = paddingTop + i * lineHeight - editor.scrollTop;
const top = paddingTop + i * lineHeight;
const signature = authors.map(owner => ownerName(owner)).sort((a, b) => a.localeCompare(b)).join("\u0000");
if (signature === previousAuthorSignature) return "";
previousAuthorSignature = signature;
const badges = authors.map(owner => `<span class="owner-label" style="--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`).join("");
return `<span class="owner-label-group" style="top:${top}px">${badges}</span>`;
return `<span class="owner-label-group" data-content-top="${top}">${badges}</span>`;
}).join("");
} else ownerLabels.replaceChildren();
if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
else authorshipLayer.replaceChildren();
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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[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" }); }
function previewNodeMarkdownParts(current) {
if (current.nodeType !== Node.ELEMENT_NODE) return { open: "", close: "", atomic: null };
const tag = current.tagName.toLowerCase();
if (tag === "strong" || tag === "b") return { open: "**", close: "**", atomic: null };
if (tag === "em" || tag === "i") return { open: "*", close: "*", atomic: null };
if (tag === "s" || tag === "del") return { open: "~~", close: "~~", atomic: null };
if (tag === "mark") return { open: "==", close: "==", atomic: null };
if (tag === "code") return { open: "`", close: "`", atomic: null };
if (tag === "sub") return { open: "~", close: "~", atomic: null };
if (tag === "sup" && !current.classList.contains("footnote-ref")) return { open: "^", close: "^", atomic: null };
if (tag === "a") return { open: "[", close: `](${current.getAttribute("href") || "#"})`, atomic: null };
if (tag === "img") {
const src = current.getAttribute("src") || "";
const alt = current.getAttribute("alt") || "";
const title = current.getAttribute("title");
return { open: "", close: "", atomic: `![${alt}](${src}${title ? ` "${title.replace(/"/g, "&quot;")}"` : ""})` };
}
if (tag === "br") return { open: "", close: "", atomic: "\n" };
return { open: "", close: "", atomic: null };
}
function previewNodeMarkdown(current) {
if (current.nodeType === Node.TEXT_NODE) return (current.nodeValue || "").replace(/\u00a0/g, " ");
if (current.nodeType !== Node.ELEMENT_NODE) return "";
const parts = previewNodeMarkdownParts(current);
if (parts.atomic !== null) return parts.atomic;
const body = [...current.childNodes].map(previewNodeMarkdown).join("");
return `${parts.open}${body}${parts.close}`;
}
function markdownFromPreview(node) {
const walk = current => {
if (current.nodeType === Node.TEXT_NODE) return current.nodeValue || "";
if (current.nodeType !== Node.ELEMENT_NODE) return "";
const tag = current.tagName.toLowerCase(), body = [...current.childNodes].map(walk).join("");
if (tag === "strong" || tag === "b") return `**${body}**`;
if (tag === "em" || tag === "i") return `*${body}*`;
if (tag === "s" || tag === "del") return `~~${body}~~`;
if (tag === "mark") return `==${body}==`;
if (tag === "code") return "`" + body + "`";
if (tag === "sub") return `~${body}~`;
if (tag === "sup" && !current.classList.contains("footnote-ref")) return `^${body}^`;
if (tag === "a") return `[${body}](${current.getAttribute("href") || "#"})`;
if (tag === "img") {
const src = current.getAttribute("src") || "";
const alt = current.getAttribute("alt") || "";
const title = current.getAttribute("title");
return `![${alt}](${src}${title ? ` "${title.replace(/"/g, "&quot;")}"` : ""})`;
return [...node.childNodes].map(previewNodeMarkdown).join("");
}
function markdownPointOffset(root, container, offset) {
let result = 0;
let found = false;
const contains = (parent, child) => parent === child || (parent.nodeType === Node.ELEMENT_NODE && parent.contains(child));
const walk = (current, isRoot = false) => {
if (found) return;
if (current === container) {
if (current.nodeType === Node.TEXT_NODE) result += Math.max(0, Math.min(offset, (current.nodeValue || "").length));
else if (current.nodeType === Node.ELEMENT_NODE) {
const parts = isRoot ? { open: "", close: "", atomic: null } : previewNodeMarkdownParts(current);
if (parts.atomic !== null) result += offset > 0 ? parts.atomic.length : 0;
else {
result += parts.open.length;
const children = [...current.childNodes];
for (let index = 0; index < Math.min(offset, children.length); index++) result += previewNodeMarkdown(children[index]).length;
}
}
found = true;
return;
}
if (tag === "br") return "\n";
return body;
if (current.nodeType === Node.TEXT_NODE) {
result += (current.nodeValue || "").length;
return;
}
if (current.nodeType !== Node.ELEMENT_NODE) return;
const parts = isRoot ? { open: "", close: "", atomic: null } : previewNodeMarkdownParts(current);
if (parts.atomic !== null) {
result += parts.atomic.length;
return;
}
result += parts.open.length;
for (const child of current.childNodes) {
if (contains(child, container)) {
walk(child);
return;
}
result += previewNodeMarkdown(child).length;
}
result += parts.close.length;
};
return [...node.childNodes].map(walk).join("").replace(/\u00a0/g, " ");
walk(root, true);
return found ? result : 0;
}
function previewCaretOffset(target) {
@@ -186,11 +252,40 @@ export function startNoteEditor(adapter) {
const range = document.createRange(); range.selectNodeContents(target); range.collapse(false);
const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range);
}
function deactivatePreviewEdit(target) {
if (!target) return;
target.removeAttribute("contenteditable");
target.removeAttribute("spellcheck");
target.classList.remove("preview-editable--active");
}
function activatePreviewEdit(target, offset = null) {
if (!target) return;
const active = preview.querySelector('.preview-editable[contenteditable="true"]');
if (active && active !== target) {
const targetIndex = [...preview.querySelectorAll(".preview-editable")].indexOf(target);
active.blur();
target = preview.querySelectorAll(".preview-editable")[targetIndex];
if (!target) return;
}
target.setAttribute("contenteditable", "true");
target.setAttribute("spellcheck", "true");
target.classList.add("preview-editable--active");
target.focus({ preventScroll: true });
placePreviewCaret(target, offset == null ? previewCaretOffset(target) : offset);
}
function movePreviewCaret(target, direction) {
const editables = [...preview.querySelectorAll(".preview-editable")];
const index = editables.indexOf(target), next = editables[index + direction];
const index = editables.indexOf(target);
if (index < 0 || !editables[index + direction]) return false;
const offset = previewCaretOffset(target);
target.blur();
const next = [...preview.querySelectorAll(".preview-editable")][index + direction];
if (!next) return false;
const offset = previewCaretOffset(target); next.focus(); placePreviewCaret(next, offset); next.scrollIntoView({ block: "nearest" }); return true;
activatePreviewEdit(next, offset);
next.scrollIntoView({ block: "nearest" });
return true;
}
function continueIndentation(event) {
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
@@ -212,6 +307,129 @@ export function startNoteEditor(adapter) {
const cells = body.split("|").map(cell => cell.trim()); while (cells.length <= index) cells.push(""); cells[index] = value.replace(/\|/g, "&#124;");
return `${leading ? "| " : ""}${cells.join(" | ")}${trailing ? " |" : ""}`;
}
function sourceLineBounds(lineIndex) {
const lines = editor.value.split("\n");
if (lineIndex < 0 || lineIndex >= lines.length) return null;
let start = 0;
for (let index = 0; index < lineIndex; index++) start += lines[index].length + 1;
return { start, end: start + lines[lineIndex].length, text: lines[lineIndex], lineIndex };
}
function tableCellBounds(line, cellIndex) {
const first = line.search(/\S|$/);
const trailingWhitespace = (line.match(/\s*$/) || [""])[0].length;
let bodyStart = first;
let bodyEnd = line.length - trailingWhitespace;
if (line[bodyStart] === "|") bodyStart++;
if (bodyEnd > bodyStart && line[bodyEnd - 1] === "|") bodyEnd--;
const body = line.slice(bodyStart, bodyEnd);
const segments = [];
let segmentStart = 0;
for (let index = 0; index <= body.length; index++) {
if (index === body.length || body[index] === "|") {
const raw = body.slice(segmentStart, index);
const left = (raw.match(/^\s*/) || [""])[0].length;
const right = (raw.match(/\s*$/) || [""])[0].length;
segments.push({ start: bodyStart + segmentStart + left, end: bodyStart + index - right });
segmentStart = index + 1;
}
}
return segments[cellIndex] || { start: bodyStart, end: bodyStart };
}
function editableSourceBounds(target) {
const lineIndex = Number(target?.dataset.sourceLine) - 1;
const line = sourceLineBounds(lineIndex);
if (!line) return null;
if (target.dataset.tableCell !== undefined) {
const cell = tableCellBounds(line.text, Number(target.dataset.tableCell));
return { start: line.start + cell.start, end: line.start + cell.end, line };
}
const prefix = target.dataset.rawSourceEdit === "true" ? "" : (target.dataset.sourcePrefix || "");
const suffix = target.dataset.rawSourceEdit === "true" ? "" : (target.dataset.sourceSuffix || "");
return {
start: Math.min(line.end, line.start + prefix.length),
end: Math.max(line.start, line.end - suffix.length),
line,
};
}
function editableAtBoundary(container, offset, preferPrevious = false) {
const element = container.nodeType === Node.ELEMENT_NODE ? container : container.parentElement;
const direct = element?.closest?.(".preview-editable");
if (direct && preview.contains(direct)) return direct;
if (container.nodeType !== Node.ELEMENT_NODE) return null;
const children = [...container.childNodes];
const candidate = preferPrevious ? children[Math.max(0, offset - 1)] : children[Math.min(offset, children.length - 1)];
const candidates = candidate ? [candidate] : [];
for (const node of candidates) {
const candidateElement = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
if (candidateElement?.matches?.(".preview-editable")) return candidateElement;
const nested = candidateElement?.querySelectorAll?.(".preview-editable");
if (nested?.length) return preferPrevious ? nested[nested.length - 1] : nested[0];
}
const source = element?.closest?.(".preview-source-line");
const nested = source?.querySelectorAll?.(".preview-editable");
return nested?.length ? (preferPrevious ? nested[nested.length - 1] : nested[0]) : null;
}
function previewPointSourceOffset(container, offset, preferPrevious = false) {
const target = editableAtBoundary(container, offset, preferPrevious);
const bounds = editableSourceBounds(target);
if (!target || !bounds) return null;
let bodyOffset;
if (target === container || target.contains(container)) bodyOffset = markdownPointOffset(target, container, offset);
else bodyOffset = preferPrevious ? bounds.end - bounds.start : 0;
return {
offset: Math.max(bounds.start, Math.min(bounds.end, bounds.start + bodyOffset)),
target,
bounds,
};
}
function previewSelectionSourceRange({ expandWholeLines = false } = {}) {
const selection = window.getSelection();
if (!selection?.rangeCount || selection.isCollapsed) return null;
const range = selection.getRangeAt(0);
const startInside = range.startContainer === preview || preview.contains(range.startContainer);
const endInside = range.endContainer === preview || preview.contains(range.endContainer);
if (!startInside || !endInside) return null;
const startPoint = previewPointSourceOffset(range.startContainer, range.startOffset, false);
const endPoint = previewPointSourceOffset(range.endContainer, range.endOffset, true);
if (!startPoint || !endPoint) return null;
let start = Math.min(startPoint.offset, endPoint.offset);
let end = Math.max(startPoint.offset, endPoint.offset);
if (expandWholeLines && startPoint.bounds.line.lineIndex !== endPoint.bounds.line.lineIndex) {
if (start === startPoint.bounds.start) start = startPoint.bounds.line.start;
if (end === endPoint.bounds.end) {
end = endPoint.bounds.line.end;
if (end < editor.value.length && editor.value[end] === "\n") end++;
}
}
return { start, end };
}
function deletePreviewSelection() {
const range = previewSelectionSourceRange({ expandWholeLines: true });
if (!range || range.end <= range.start) return false;
editor.setRangeText("", range.start, range.end, "end");
editor.dispatchEvent(new Event("input", { bubbles: true }));
return true;
}
function previewShortcutFormat(event) {
const primary = event.ctrlKey || event.metaKey;
if (primary && !event.shiftKey && event.key.toLowerCase() === "b") return "bold";
if (primary && !event.shiftKey && event.key.toLowerCase() === "i") return "italic";
if (primary && event.shiftKey && event.key.toLowerCase() === "x") return "strike";
if (primary && !event.shiftKey && event.key.toLowerCase() === "k") return "link";
if (primary && event.shiftKey && event.key === "7") return "number";
if (primary && event.shiftKey && event.key === "8") return "bullet";
if (primary && event.shiftKey && event.key === "9") return "task";
if (event.altKey && /^[1-4]$/.test(event.key)) return `heading${event.key}`;
return null;
}
function syncPreviewScroll() {
if (activeView() !== "split") return;
const editorRange = Math.max(0, editor.scrollHeight - editor.clientHeight);
@@ -219,7 +437,7 @@ export function startNoteEditor(adapter) {
const ratio = editorRange > 0 ? editor.scrollTop / editorRange : 0;
preview.scrollTop = ratio * previewRange;
}
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(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}" contenteditable="true" spellcheck="true">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } 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 render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(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 · editable"; } 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 activeView() {
return compactLayoutQuery.matches ? compactView : uiState.view;
}
@@ -242,7 +460,48 @@ export function startNoteEditor(adapter) {
if (write) writeEditorState(uiState, { replace });
updateAddressLabel();
}
function applyRemote(content, ownerMap) { if (content === editor.value && ownerMap == null) return; const previous = editor.value, start = editor.selectionStart, end = editor.selectionEnd, direction = editor.selectionDirection, scrollTop = editor.scrollTop, scrollLeft = editor.scrollLeft; const mapped = mapSelectionThroughEdit(previous, content, start, end); applyingRemote = true; editor.value = content; authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length); previousContent = content; editor.setSelectionRange(mapped.start, mapped.end, direction); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; applyingRemote = false; render(); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; authorshipLayer.scrollTop = scrollTop; authorshipLayer.scrollLeft = scrollLeft; }
function previewEditSnapshot() {
const target = document.activeElement?.closest?.('.preview-editable[contenteditable="true"]');
if (!target || !preview.contains(target)) return null;
return {
index: [...preview.querySelectorAll(".preview-editable")].indexOf(target),
offset: previewCaretOffset(target),
raw: target.dataset.rawSourceEdit === "true",
};
}
function restorePreviewEdit(snapshot) {
if (!snapshot || snapshot.index < 0) return;
const target = preview.querySelectorAll(".preview-editable")[snapshot.index];
if (!target) return;
activatePreviewEdit(target, snapshot.offset);
if (snapshot.raw) editRawPreviewLine(target);
}
function applyRemote(content, ownerMap) {
if (content === editor.value) {
if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length);
previousContent = content;
renderGutter();
return;
}
const previewSnapshot = previewEditSnapshot();
const previous = editor.value, start = editor.selectionStart, end = editor.selectionEnd, direction = editor.selectionDirection, scrollTop = editor.scrollTop, scrollLeft = editor.scrollLeft;
const mapped = mapSelectionThroughEdit(previous, content, start, end);
applyingRemote = true;
editor.value = content;
authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length);
previousContent = content;
editor.setSelectionRange(mapped.start, mapped.end, direction);
editor.scrollTop = scrollTop;
editor.scrollLeft = scrollLeft;
applyingRemote = false;
render();
editor.scrollTop = scrollTop;
editor.scrollLeft = scrollLeft;
syncEditorLayers();
restorePreviewEdit(previewSnapshot);
}
const { loadFiles } = bindNoteFiles({
editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files),
@@ -339,23 +598,7 @@ export function startNoteEditor(adapter) {
clearUnread();
requestAnimationFrame(() => chatInput.focus());
});
const mobileTimeButton = document.querySelector("#mobile-time-button");
const mobileTimePreview = document.querySelector("#mobile-time-preview");
let mobileTimeTimer = null;
function updateMobileTime() {
if (!mobileTimePreview) return;
mobileTimePreview.textContent = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(new Date());
}
mobileTimeButton?.addEventListener("click", event => {
event.stopPropagation();
const willOpen = mobileTimePreview.hidden;
mobileTimePreview.hidden = !willOpen;
clearInterval(mobileTimeTimer);
mobileTimeTimer = null;
if (willOpen) { updateMobileTime(); mobileTimeTimer = setInterval(updateMobileTime, 1000); }
});
document.addEventListener("click", event => {
if (!event.target.closest("#mobile-time-button, #mobile-time-preview") && mobileTimePreview) { mobileTimePreview.hidden = true; clearInterval(mobileTimeTimer); mobileTimeTimer = null; }
if (roomDetails.classList.contains("is-mobile-open") && !event.target.closest("#mobile-chat-button, #room-details")) { roomDetails.open = false; roomDetails.classList.remove("is-mobile-open"); }
});
const mobileBubble = document.querySelector("#mobile-editor-bubble");
@@ -407,13 +650,19 @@ export function startNoteEditor(adapter) {
const value = markdownFromPreview(target);
const lines = editor.value.split("\n");
if (target.dataset.rawSourceEdit === "true") {
if (value === lines[lineIndex]) return;
if (value === lines[lineIndex]) {
render();
return;
}
lines[lineIndex] = value.replace(/\n/g, "");
editor.value = lines.join("\n");
editor.dispatchEvent(new Event("input", { bubbles: true }));
return;
}
if (!focusNextLine && value === target.dataset.originalValue) return;
if (!focusNextLine && value === target.dataset.originalValue) {
deactivatePreviewEdit(target);
return;
}
if (focusNextLine) cancelledPreviewEdits.add(target);
if (target.dataset.tableCell !== undefined) {
lines[lineIndex] = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value.replace(/\n/g, " "));
@@ -429,8 +678,7 @@ export function startNoteEditor(adapter) {
if (focusNextLine) {
const nextLine = lineIndex + Math.max(2, value.split("\n").length);
const next = preview.querySelector(`[data-source-line="${nextLine}"].preview-editable`);
next?.focus();
if (next) placePreviewCaret(next, 0);
if (next) activatePreviewEdit(next, 0);
}
}
function editRawPreviewLine(target) {
@@ -438,6 +686,9 @@ export function startNoteEditor(adapter) {
const lines = editor.value.split("\n");
if (lineIndex < 0 || lineIndex >= lines.length) return;
const caretOffset = Math.min(previewCaretOffset(target), lines[lineIndex].length);
target.setAttribute("contenteditable", "true");
target.setAttribute("spellcheck", "true");
target.classList.add("preview-editable--active");
target.dataset.rawSourceEdit = "true";
target.dataset.originalValue = lines[lineIndex];
target.textContent = lines[lineIndex];
@@ -470,8 +721,7 @@ export function startNoteEditor(adapter) {
const restoreFocus = () => {
const next = preview.querySelector(`[data-source-line="${sourceLine}"].preview-editable`);
if (!next) return;
next.focus({ preventScroll: true });
placePreviewCaret(next, 0);
activatePreviewEdit(next, 0);
};
restoreFocus();
queueMicrotask(() => {
@@ -501,7 +751,115 @@ export function startNoteEditor(adapter) {
saveEditorSettingsButton.disabled = !info?.can_save_editor_settings;
}
});
window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); 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()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("focusin", event => { const target = event.target.closest(".preview-editable"); if (!target) return; target.dataset.originalHtml = target.innerHTML; target.dataset.originalValue = markdownFromPreview(target); }); preview.addEventListener("beforeinput", event => { if (!event.target.closest(".preview-editable")) return; if (event.inputType === "insertParagraph" || event.inputType === "insertLineBreak") event.preventDefault(); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); if (target.dataset.rawSourceEdit === "true") { cancelledPreviewEdits.add(target); render(); } else editRawPreviewLine(target); return; } if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); if (event.altKey) insertPreviewLineBreak(target); else target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (cancelledPreviewEdits.has(target)) { cancelledPreviewEdits.delete(target); return; } commitPreviewEdit(target); }, { capture: true });
window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); });
window.addEventListener("rustpad:urlchange", updateAddressLabel);
document.querySelector("#copy-link").addEventListener("click", async () => {
try { await copyText(currentShareUrl(uiState)); toast("Link copied"); }
catch (error) { toast(error.message); }
});
let pendingPreviewFormatRange = null;
document.querySelectorAll("[data-format]").forEach(button => {
button.addEventListener("pointerdown", event => {
pendingPreviewFormatRange = previewSelectionSourceRange();
if (pendingPreviewFormatRange) event.preventDefault();
});
button.addEventListener("click", () => {
const range = pendingPreviewFormatRange || previewSelectionSourceRange();
pendingPreviewFormatRange = null;
if (range) editor.setSelectionRange(range.start, range.end);
applyFormat(editor, button.dataset.format);
button.closest("details")?.removeAttribute("open");
});
});
bindFormatShortcuts(editor);
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());
preview.addEventListener("click", event => {
const target = event.target.closest(".preview-editable");
if (!target || event.target.closest("a, button, input, img")) return;
const selection = window.getSelection();
if (selection && !selection.isCollapsed) return;
const offset = previewCaretOffset(target);
activatePreviewEdit(target, offset);
});
preview.addEventListener("change", event => {
const checkbox = event.target.closest(".task-checkbox");
if (!checkbox) return;
const lineIndex = Number(checkbox.dataset.sourceLine) - 1;
const lines = editor.value.split("\n");
if (lineIndex < 0 || lineIndex >= lines.length) return;
lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`);
editor.value = lines.join("\n");
editor.dispatchEvent(new Event("input", { bubbles: true }));
});
preview.addEventListener("focusin", event => {
const target = event.target.closest('.preview-editable[contenteditable="true"]');
if (!target) return;
target.dataset.originalHtml = target.innerHTML;
target.dataset.originalValue = markdownFromPreview(target);
});
preview.addEventListener("beforeinput", event => {
if (!event.target.closest('.preview-editable[contenteditable="true"]')) return;
if (event.inputType === "insertParagraph" || event.inputType === "insertLineBreak") event.preventDefault();
});
preview.addEventListener("keydown", event => {
const target = event.target.closest('.preview-editable[contenteditable="true"]');
if (!target) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
if (target.dataset.rawSourceEdit === "true") {
cancelledPreviewEdits.add(target);
render();
} else editRawPreviewLine(target);
return;
}
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
if (event.altKey) insertPreviewLineBreak(target);
else target.blur();
return;
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault();
}
});
preview.addEventListener("blur", event => {
const target = event.target.closest(".preview-editable");
if (!target) return;
if (cancelledPreviewEdits.has(target)) {
cancelledPreviewEdits.delete(target);
return;
}
commitPreviewEdit(target);
}, { capture: true });
document.addEventListener("keydown", event => {
if (document.activeElement?.closest?.('.preview-editable[contenteditable="true"]')) return;
const range = previewSelectionSourceRange();
if (!range) return;
if (event.key === "Backspace" || event.key === "Delete") {
event.preventDefault();
deletePreviewSelection();
return;
}
const format = previewShortcutFormat(event);
if (!format) return;
event.preventDefault();
editor.setSelectionRange(range.start, range.end);
applyFormat(editor, format);
});
document.addEventListener("cut", event => {
const range = previewSelectionSourceRange();
if (!range) return;
event.preventDefault();
event.clipboardData?.setData("text/plain", window.getSelection()?.toString() || "");
deletePreviewSelection();
});
const publishPageButton = document.querySelector("#publish-page");
function updatePageControls() {
const enabled = publicPageEnabled.checked;
@@ -553,7 +911,23 @@ export function startNoteEditor(adapter) {
if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
toast("Global profile color restored");
});
editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; authorshipLayer.scrollTop = editor.scrollTop; authorshipLayer.scrollLeft = editor.scrollLeft; renderGutter(); syncPreviewScroll(); }); editor.addEventListener("input", () => { const nextContent = editor.value; authorship = adoptCurrentOwnerAliases(authorship, previousContent.length); authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); });
editor.addEventListener("keydown", continueIndentation);
editor.addEventListener("scroll", () => {
syncEditorLayers();
syncPreviewScroll();
});
editor.addEventListener("input", () => {
const nextContent = editor.value;
authorship = adoptCurrentOwnerAliases(authorship, previousContent.length);
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length);
authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner());
previousContent = nextContent;
render();
if (applyingRemote) return;
clearTimeout(saveTimer);
document.querySelector("#save-state").textContent = "Saving…";
saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250);
});
passwordDialog.addEventListener("cancel", event => {
if (info?.protected && !resourceUnlocked) {
event.preventDefault();