v0.2.87
This commit is contained in:
1 parent
acfe926066
commit
df403c0aa7
26 files changed
+1502
-144
No files matched your search
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* 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 };
|
||||
}
|
||||
Reference in new issue
Block a user