193 lines
7.8 KiB
JavaScript
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 };
|
|
}
|