Files
rustpad/static/js/mention-navigation.js
T
2026-10-18 14:45:59 +02:00

193 lines
7.8 KiB
JavaScript

/*
* 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.
*/
import { t } from "@rustpad/i18n";
import { normalizeMentionUsers } from "@rustpad/mention-autocomplete";
function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
export function collectMentionOccurrences(source, authorizedUsers, { markdown = true } = {}) {
const members = normalizeMentionUsers(authorizedUsers);
if (!members.length) return [];
const lookup = new Map(members.map(member => [member.nickname.toLocaleLowerCase(), member]));
const names = members.map(member => member.nickname).sort((a, b) => b.length - a.length);
const matcher = new RegExp(`(^|[^\\p{L}\\p{N}_@])@(${names.map(escapeRegex).join("|")})(?![\\p{L}\\p{N}_-]|\\.[\\p{L}\\p{N}_-])`, "giu");
const found = [];
let offset = 0, fence = "";
for (const [lineIndex, line] of String(source).split("\n").entries()) {
if (markdown) {
const fenceMatch = line.match(/^([ \t]*)(`{3,}|~{3,})[ \t]*([^\s]*)[ \t]*$/);
if (fenceMatch) {
const marker = fenceMatch[2];
if (!fence) fence = marker;
else if (marker[0] === fence[0] && marker.length >= fence.length && !fenceMatch[3]) fence = "";
offset += line.length + 1;
continue;
}
if (fence) { offset += line.length + 1; continue; }
}
const excluded = [];
if (markdown) {
const hidden = [
/`[^`]+`/g,
/!?\[[^\]]*\]\([^\s)]+(?:\s+["'][^"']*["'])?\)/g,
/\[(?:file|image|img|video)=[^\]\n]*\]/gi,
/(?:https?:\/\/|mailto:)[^\s<>]+/gi,
];
for (const expression of hidden) {
for (const match of line.matchAll(expression)) excluded.push([match.index, match.index + match[0].length]);
}
}
matcher.lastIndex = 0;
let match;
while ((match = matcher.exec(line)) !== null) {
const startInLine = match.index + match[1].length;
if (excluded.some(([start, end]) => startInLine >= start && startInLine < end)) continue;
const member = lookup.get(match[2].toLocaleLowerCase());
if (!member) continue;
const start = offset + startInLine;
found.push({
key: member.nickname.toLocaleLowerCase(),
nickname: member.nickname,
color: member.color,
start,
end: start + 1 + match[2].length,
sourceLine: lineIndex + 1,
});
}
offset += line.length + 1;
}
return found;
}
export function createMentionNavigator({ container, list, previewContainer, previewList, editor, preview, getView, onEditorScroll }) {
const targets = [{ container, list }];
if (previewContainer && previewList) targets.push({ container: previewContainer, list: previewList });
let users = [], entries = new Map(), lastJump = new Map();
function setUsers(value) {
users = normalizeMentionUsers(value);
lastJump.clear();
}
function render({ markdown = true } = {}) {
const found = collectMentionOccurrences(editor.value, users, { markdown });
entries = new Map();
for (const mention of found) {
if (!entries.has(mention.key)) entries.set(mention.key, []);
entries.get(mention.key).push(mention);
}
if (markdown) {
const visible = new Map();
for (const node of preview.querySelectorAll(".markdown-mention")) {
const key = (node.textContent || "").replace(/^@/, "").toLocaleLowerCase();
const sourceLine = Number(node.closest("[data-source-line]")?.dataset.sourceLine);
if (!Number.isInteger(sourceLine)) continue;
const lookupKey = `${key}\u0000${sourceLine}`;
if (!visible.has(lookupKey)) visible.set(lookupKey, []);
visible.get(lookupKey).push(node);
}
for (const [key, matches] of entries) {
for (const mention of matches) {
const candidates = visible.get(`${key}\u0000${mention.sourceLine}`);
mention.previewNode = candidates?.shift() || null;
}
}
}
for (const { container, list } of targets) {
container.hidden = entries.size === 0;
const badges = [];
for (const [key, matches] of entries) {
const first = matches[0];
const button = document.createElement("button");
button.type = "button";
button.className = "mentioned-users__badge";
button.dataset.mentionKey = key;
button.style.setProperty("--owner", first.color);
const name = document.createElement("span");
name.textContent = `@${first.nickname}`;
const count = document.createElement("span");
count.className = "mentioned-users__count";
count.textContent = String(matches.length);
button.append(name, count);
updateButtonHint(button, first.nickname, matches.length);
badges.push(button);
}
list.replaceChildren(...badges);
}
}
function updateButtonHint(button, nickname, count, current = 0) {
const label = t("editor.mentionJump", { name: nickname, position: current + 1, count },
`@${nickname}: go to mention ${current + 1} of ${count}`);
button.title = label;
button.setAttribute("aria-label", label);
}
function navigatePreview(mention) {
const node = mention.previewNode?.isConnected ? mention.previewNode
: preview.querySelector(`[data-source-line="${mention.sourceLine}"]`);
if (!node) return;
for (let parent = node.parentElement; parent && parent !== preview; parent = parent.parentElement) {
if (parent.matches("details.markdown-details")) parent.open = true;
}
preview.querySelector(".markdown-mention.is-mention-target")?.classList.remove("is-mention-target");
if (node.classList.contains("markdown-mention")) node.classList.add("is-mention-target");
const bounds = node.getBoundingClientRect();
const viewport = preview.getBoundingClientRect();
preview.scrollTop += bounds.top - viewport.top - Math.min(72, preview.clientHeight * 0.2);
}
function jumpTo(key) {
const matches = entries.get(key);
if (!matches?.length) return null;
const previous = lastJump.get(key) ?? -1;
const index = Math.max(0, matches.findIndex(item => item.start > previous));
const mention = matches[index];
lastJump.set(key, mention.start);
const view = getView();
if (view !== "preview") {
const style = getComputedStyle(editor);
const lineHeight = parseFloat(style.lineHeight) || 29;
const paddingTop = parseFloat(style.paddingTop) || 0;
const target = paddingTop + (mention.sourceLine - 1) * lineHeight;
editor.scrollTop = Math.max(0, target - Math.min(72, editor.clientHeight * 0.2));
editor.setSelectionRange(mention.start, mention.end);
editor.focus({ preventScroll: true });
onEditorScroll?.();
}
if (view !== "edit") {
navigatePreview(mention);
if (view === "split") requestAnimationFrame(() => {
if (lastJump.get(key) === mention.start) navigatePreview(mention);
});
}
for (const { list } of targets) {
const button = [...list.querySelectorAll("[data-mention-key]")].find(node => node.dataset.mentionKey === key);
if (button) {
button.querySelector(".mentioned-users__count").textContent = `${index + 1}/${matches.length}`;
updateButtonHint(button, mention.nickname, matches.length, (index + 1) % matches.length);
}
}
return { ...mention, index, count: matches.length };
}
for (const { list } of targets) {
list.addEventListener("click", event => {
const button = event.target.closest("button[data-mention-key]");
if (button && list.contains(button)) jumpTo(button.dataset.mentionKey);
});
}
return { setUsers, render, jumpTo };
}