/* * 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, "\\$&"); } // Find real @mentions in the source, not arbitrary text that resembles an email // address or an @username inside a code fence, inline code, link or URL. // Offsets use UTF-16 code units, just like textarea.setSelectionRange(). 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) { // The Markdown renderer stashes these constructs before highlighting mentions. 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); } // Keep a reference to each visible mention, so the jump can target the // exact chip even if several mentions are on the same source line. 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); // Split view scroll synchronization may run after the textarea scroll event. 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 }; }