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,275 @@
|
||||
/*
|
||||
* 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";
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
const MENTION_SUFFIX = /[\p{L}\p{M}\p{N}_.+-]{0,64}$/u;
|
||||
const MENTION_BOUNDARY = /[\p{L}\p{N}_@]/u;
|
||||
|
||||
// A suggestion is offered only for an @word being typed at a collapsed caret.
|
||||
// Do not interpret addresses such as john@example.org as mentions.
|
||||
export function mentionAtCaret(value, caret) {
|
||||
if (caret < 1 || caret > value.length) return null;
|
||||
const lineStart = value.lastIndexOf("\n", caret - 1) + 1;
|
||||
const before = value.slice(lineStart, caret);
|
||||
const suffix = MENTION_SUFFIX.exec(before);
|
||||
if (!suffix) return null;
|
||||
const at = caret - suffix[0].length - 1;
|
||||
if (at < lineStart || value[at] !== "@") return null;
|
||||
if (at > 0 && MENTION_BOUNDARY.test(value[at - 1])) return null;
|
||||
return { start: at, end: caret, query: suffix[0] };
|
||||
}
|
||||
|
||||
function fallbackColor(name) {
|
||||
let hash = 0;
|
||||
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) % 360;
|
||||
return `hsl(${hash} 70% 62%)`;
|
||||
}
|
||||
|
||||
export function normalizeMentionUsers(users) {
|
||||
const seen = new Set();
|
||||
return (Array.isArray(users) ? users : []).flatMap(user => {
|
||||
const nickname = String(user?.nickname || "").trim();
|
||||
const key = nickname.toLocaleLowerCase();
|
||||
if (!nickname || seen.has(key)) return [];
|
||||
seen.add(key);
|
||||
return [{
|
||||
nickname,
|
||||
color: /^#[0-9a-f]{6}$/i.test(user?.color || "") ? user.color : fallbackColor(nickname),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export function filterMentionUsers(users, query) {
|
||||
const lower = String(query || "").toLocaleLowerCase();
|
||||
return users.filter(user => user.nickname.toLocaleLowerCase().includes(lower))
|
||||
.sort((a, b) => {
|
||||
const aStarts = a.nickname.toLocaleLowerCase().startsWith(lower);
|
||||
const bStarts = b.nickname.toLocaleLowerCase().startsWith(lower);
|
||||
return Number(bStarts) - Number(aStarts) || a.nickname.localeCompare(b.nickname);
|
||||
})
|
||||
.slice(0, MAX_RESULTS);
|
||||
}
|
||||
|
||||
export function createMentionAutocomplete(editor) {
|
||||
const shell = editor.closest(".editor-shell");
|
||||
if (!shell) throw new Error("Mention suggestions require an editor shell.");
|
||||
const menu = document.createElement("div");
|
||||
menu.id = "mention-suggestions";
|
||||
menu.className = "mention-suggestions";
|
||||
menu.setAttribute("role", "listbox");
|
||||
menu.setAttribute("aria-label", t("editor.mentionMembers", {}, "Mention a member"));
|
||||
menu.hidden = true;
|
||||
shell.append(menu);
|
||||
|
||||
// A single-line offscreen mirror measures the caret's horizontal position,
|
||||
// including proportional fonts and tabs, without relying on text estimates.
|
||||
const mirror = document.createElement("div");
|
||||
mirror.className = "mention-caret-mirror";
|
||||
mirror.setAttribute("aria-hidden", "true");
|
||||
shell.append(mirror);
|
||||
|
||||
editor.setAttribute("aria-controls", menu.id);
|
||||
editor.setAttribute("aria-haspopup", "listbox");
|
||||
editor.setAttribute("aria-expanded", "false");
|
||||
|
||||
let members = [];
|
||||
let matches = [];
|
||||
let activeMatch = null;
|
||||
let selectedIndex = 0;
|
||||
let composing = false;
|
||||
let dismissedAt = null;
|
||||
let blurTimer = null;
|
||||
|
||||
function close({ dismissed = false } = {}) {
|
||||
if (dismissed && activeMatch) dismissedAt = `${activeMatch.start}:${activeMatch.end}:${activeMatch.query}`;
|
||||
activeMatch = null;
|
||||
matches = [];
|
||||
menu.hidden = true;
|
||||
menu.replaceChildren();
|
||||
editor.setAttribute("aria-expanded", "false");
|
||||
editor.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
|
||||
function position() {
|
||||
if (menu.hidden || !activeMatch) return;
|
||||
const style = getComputedStyle(editor);
|
||||
const lineStart = editor.value.lastIndexOf("\n", activeMatch.end - 1) + 1;
|
||||
const beforeCaret = editor.value.slice(lineStart, activeMatch.end);
|
||||
mirror.style.font = style.font;
|
||||
mirror.style.letterSpacing = style.letterSpacing;
|
||||
mirror.style.tabSize = style.tabSize;
|
||||
mirror.textContent = beforeCaret;
|
||||
const cursor = document.createElement("span");
|
||||
cursor.textContent = "\u200b";
|
||||
mirror.append(cursor);
|
||||
const measuredX = cursor.getBoundingClientRect().left - mirror.getBoundingClientRect().left;
|
||||
|
||||
const shellRect = shell.getBoundingClientRect();
|
||||
const editorRect = editor.getBoundingClientRect();
|
||||
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.72;
|
||||
const lineIndex = editor.value.slice(0, lineStart).split("\n").length - 1;
|
||||
const x = editorRect.left - shellRect.left + (parseFloat(style.borderLeftWidth) || 0)
|
||||
+ (parseFloat(style.paddingLeft) || 0) + measuredX - editor.scrollLeft;
|
||||
const y = editorRect.top - shellRect.top + (parseFloat(style.borderTopWidth) || 0)
|
||||
+ (parseFloat(style.paddingTop) || 0) + lineIndex * lineHeight - editor.scrollTop;
|
||||
if (x < -5 || x > shell.clientWidth + 5 || y < -lineHeight || y > shell.clientHeight) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
const edge = 6;
|
||||
const bottom = y + lineHeight + 4;
|
||||
const top = bottom + menu.offsetHeight > shell.clientHeight - edge
|
||||
? y - menu.offsetHeight - 4 : bottom;
|
||||
menu.style.left = `${Math.max(edge, Math.min(x, shell.clientWidth - menu.offsetWidth - edge))}px`;
|
||||
menu.style.top = `${Math.max(edge, Math.min(top, shell.clientHeight - menu.offsetHeight - edge))}px`;
|
||||
}
|
||||
|
||||
function markSelected() {
|
||||
[...menu.querySelectorAll('[role="option"]')].forEach((option, i) => {
|
||||
const selected = i === selectedIndex;
|
||||
option.setAttribute("aria-selected", String(selected));
|
||||
if (selected) {
|
||||
editor.setAttribute("aria-activedescendant", option.id);
|
||||
if (option.offsetTop < menu.scrollTop) menu.scrollTop = option.offsetTop;
|
||||
else if (option.offsetTop + option.offsetHeight > menu.scrollTop + menu.clientHeight) {
|
||||
menu.scrollTop = option.offsetTop + option.offsetHeight - menu.clientHeight;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function accept(index) {
|
||||
const user = matches[index];
|
||||
if (!user || !activeMatch || editor.readOnly) return;
|
||||
const { start, end } = activeMatch;
|
||||
if (editor.value.slice(start, end) !== `@${activeMatch.query}`) { close(); return; }
|
||||
const after = editor.value[end] || "";
|
||||
const separator = !after || !/[\s.,;:!?)}\]]/u.test(after) ? " " : "";
|
||||
close();
|
||||
dismissedAt = null;
|
||||
editor.focus();
|
||||
const inserted = `@${user.nickname}${separator}`;
|
||||
editor.setRangeText(inserted, start, end, "end");
|
||||
// Use the normal input pipeline: live collaboration, authorship and undo/redo.
|
||||
editor.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertReplacementText", data: inserted }));
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (composing || editor.readOnly || document.activeElement !== editor ||
|
||||
editor.selectionStart !== editor.selectionEnd || !members.length) { close(); return; }
|
||||
const match = mentionAtCaret(editor.value, editor.selectionStart);
|
||||
if (!match) { dismissedAt = null; close(); return; }
|
||||
const key = `${match.start}:${match.end}:${match.query}`;
|
||||
if (dismissedAt === key) return;
|
||||
const choices = filterMentionUsers(members, match.query);
|
||||
if (!choices.length) { close(); return; }
|
||||
const previous = activeMatch;
|
||||
matches = choices;
|
||||
activeMatch = match;
|
||||
if (!previous || previous.start !== match.start || previous.query !== match.query) selectedIndex = 0;
|
||||
menu.replaceChildren();
|
||||
choices.forEach((user, index) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.id = `mention-suggestion-${index}`;
|
||||
button.className = "mention-suggestions__option";
|
||||
button.setAttribute("role", "option");
|
||||
button.style.setProperty("--owner", user.color);
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "mention-suggestions__dot";
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
const label = document.createElement("span");
|
||||
label.textContent = `@${user.nickname}`;
|
||||
button.append(dot, label);
|
||||
// Keep textarea selection intact while clicking with a mouse.
|
||||
button.addEventListener("mousedown", event => event.preventDefault());
|
||||
button.addEventListener("click", () => accept(index));
|
||||
menu.append(button);
|
||||
});
|
||||
menu.hidden = false;
|
||||
editor.setAttribute("aria-expanded", "true");
|
||||
markSelected();
|
||||
position();
|
||||
}
|
||||
|
||||
function onKeydown(event) {
|
||||
if (menu.hidden || !activeMatch || event.isComposing || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
close({ dismissed: true });
|
||||
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
selectedIndex = (selectedIndex + (event.key === "ArrowDown" ? 1 : -1) + matches.length) % matches.length;
|
||||
markSelected();
|
||||
} else if (event.key === "Enter" || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
accept(selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur(event) {
|
||||
clearTimeout(blurTimer);
|
||||
if (event.relatedTarget && menu.contains(event.relatedTarget)) return;
|
||||
blurTimer = setTimeout(() => {
|
||||
if (document.activeElement !== editor && !menu.contains(document.activeElement)) close();
|
||||
}, 120);
|
||||
}
|
||||
function onOutsidePointer(event) {
|
||||
if (!menu.contains(event.target) && event.target !== editor) close();
|
||||
}
|
||||
function onCompositionStart() { composing = true; close(); }
|
||||
function onCompositionEnd() { composing = false; refresh(); }
|
||||
const onScroll = () => { if (!menu.hidden) position(); };
|
||||
const onSelection = () => { if (document.activeElement === editor && !menu.hidden) refresh(); };
|
||||
|
||||
editor.addEventListener("keydown", onKeydown);
|
||||
editor.addEventListener("input", refresh);
|
||||
editor.addEventListener("click", refresh);
|
||||
editor.addEventListener("keyup", refresh);
|
||||
editor.addEventListener("focus", refresh);
|
||||
editor.addEventListener("blur", onBlur);
|
||||
editor.addEventListener("scroll", onScroll);
|
||||
editor.addEventListener("compositionstart", onCompositionStart);
|
||||
editor.addEventListener("compositionend", onCompositionEnd);
|
||||
document.addEventListener("pointerdown", onOutsidePointer);
|
||||
document.addEventListener("selectionchange", onSelection);
|
||||
window.addEventListener("resize", onScroll);
|
||||
|
||||
return {
|
||||
setUsers(users) { members = normalizeMentionUsers(users); dismissedAt = null; close(); refresh(); },
|
||||
close,
|
||||
refresh,
|
||||
destroy() {
|
||||
clearTimeout(blurTimer);
|
||||
editor.removeEventListener("keydown", onKeydown);
|
||||
editor.removeEventListener("input", refresh);
|
||||
editor.removeEventListener("click", refresh);
|
||||
editor.removeEventListener("keyup", refresh);
|
||||
editor.removeEventListener("focus", refresh);
|
||||
editor.removeEventListener("blur", onBlur);
|
||||
editor.removeEventListener("scroll", onScroll);
|
||||
editor.removeEventListener("compositionstart", onCompositionStart);
|
||||
editor.removeEventListener("compositionend", onCompositionEnd);
|
||||
document.removeEventListener("pointerdown", onOutsidePointer);
|
||||
document.removeEventListener("selectionchange", onSelection);
|
||||
window.removeEventListener("resize", onScroll);
|
||||
editor.removeAttribute("aria-controls");
|
||||
editor.removeAttribute("aria-haspopup");
|
||||
editor.removeAttribute("aria-expanded");
|
||||
editor.removeAttribute("aria-activedescendant");
|
||||
menu.remove();
|
||||
mirror.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user