@thoughtbot/trix-mentions-element
Version:
Activates a suggestion menu to embed attachments as you type.
566 lines (558 loc) • 19.6 kB
JavaScript
const ctrlBindings = !!navigator.userAgent.match(/Macintosh/);
class Combobox {
constructor(input, list) {
this.input = input;
this.list = list;
this.isComposing = false;
if (!list.id) {
list.id = `combobox-${Math.random()
.toString()
.slice(2, 6)}`;
}
this.keyboardEventHandler = event => keyboardBindings(event, this);
this.compositionEventHandler = event => trackComposition(event, this);
this.inputHandler = this.clearSelection.bind(this);
input.setAttribute('role', 'combobox');
input.setAttribute('aria-controls', list.id);
input.setAttribute('aria-expanded', 'false');
input.setAttribute('aria-autocomplete', 'list');
input.setAttribute('aria-haspopup', 'listbox');
}
destroy() {
this.clearSelection();
this.stop();
this.input.removeAttribute('role');
this.input.removeAttribute('aria-controls');
this.input.removeAttribute('aria-expanded');
this.input.removeAttribute('aria-autocomplete');
this.input.removeAttribute('aria-haspopup');
}
start() {
this.input.setAttribute('aria-expanded', 'true');
this.input.addEventListener('compositionstart', this.compositionEventHandler);
this.input.addEventListener('compositionend', this.compositionEventHandler);
this.input.addEventListener('input', this.inputHandler);
this.input.addEventListener('keydown', this.keyboardEventHandler);
this.list.addEventListener('click', commitWithElement);
}
stop() {
this.clearSelection();
this.input.setAttribute('aria-expanded', 'false');
this.input.removeEventListener('compositionstart', this.compositionEventHandler);
this.input.removeEventListener('compositionend', this.compositionEventHandler);
this.input.removeEventListener('input', this.inputHandler);
this.input.removeEventListener('keydown', this.keyboardEventHandler);
this.list.removeEventListener('click', commitWithElement);
}
navigate(indexDiff = 1) {
const focusEl = Array.from(this.list.querySelectorAll('[aria-selected="true"]')).filter(visible)[0];
const els = Array.from(this.list.querySelectorAll('[role="option"]')).filter(visible);
const focusIndex = els.indexOf(focusEl);
if ((focusIndex === els.length - 1 && indexDiff === 1) || (focusIndex === 0 && indexDiff === -1)) {
this.clearSelection();
this.input.focus();
return;
}
let indexOfItem = indexDiff === 1 ? 0 : els.length - 1;
if (focusEl && focusIndex >= 0) {
const newIndex = focusIndex + indexDiff;
if (newIndex >= 0 && newIndex < els.length)
indexOfItem = newIndex;
}
const target = els[indexOfItem];
if (!target)
return;
for (const el of els) {
if (target === el) {
this.input.setAttribute('aria-activedescendant', target.id);
target.setAttribute('aria-selected', 'true');
scrollTo(this.list, target);
}
else {
el.setAttribute('aria-selected', 'false');
}
}
}
clearSelection() {
this.input.removeAttribute('aria-activedescendant');
for (const el of this.list.querySelectorAll('[aria-selected="true"]')) {
el.setAttribute('aria-selected', 'false');
}
}
}
function keyboardBindings(event, combobox) {
if (event.shiftKey || event.metaKey || event.altKey)
return;
if (!ctrlBindings && event.ctrlKey)
return;
if (combobox.isComposing)
return;
switch (event.key) {
case 'Enter':
case 'Tab':
if (commit(combobox.input, combobox.list)) {
event.preventDefault();
}
break;
case 'Escape':
combobox.clearSelection();
break;
case 'ArrowDown':
combobox.navigate(1);
event.preventDefault();
break;
case 'ArrowUp':
combobox.navigate(-1);
event.preventDefault();
break;
case 'n':
if (ctrlBindings && event.ctrlKey) {
combobox.navigate(1);
event.preventDefault();
}
break;
case 'p':
if (ctrlBindings && event.ctrlKey) {
combobox.navigate(-1);
event.preventDefault();
}
break;
default:
if (event.ctrlKey)
break;
combobox.clearSelection();
}
}
function commitWithElement(event) {
if (!(event.target instanceof Element))
return;
const target = event.target.closest('[role="option"]');
if (!target)
return;
if (target.getAttribute('aria-disabled') === 'true')
return;
fireCommitEvent(target);
}
function commit(input, list) {
const target = list.querySelector('[aria-selected="true"]');
if (!target)
return false;
if (target.getAttribute('aria-disabled') === 'true')
return true;
target.click();
return true;
}
function fireCommitEvent(target) {
target.dispatchEvent(new CustomEvent('combobox-commit', { bubbles: true }));
}
function visible(el) {
return (!el.hidden &&
!(el instanceof HTMLInputElement && el.type === 'hidden') &&
(el.offsetWidth > 0 || el.offsetHeight > 0));
}
function trackComposition(event, combobox) {
combobox.isComposing = event.type === 'compositionstart';
const list = document.getElementById(combobox.input.getAttribute('aria-controls') || '');
if (!list)
return;
combobox.clearSelection();
}
function scrollTo(container, target) {
if (!inViewport(container, target)) {
container.scrollTop = target.offsetTop;
}
}
function inViewport(container, element) {
const scrollTop = container.scrollTop;
const containerBottom = scrollTop + container.clientHeight;
const top = element.offsetTop;
const bottom = top + element.clientHeight;
return top >= scrollTop && bottom <= containerBottom;
}
const boundary = /\s|\(|\[/;
function query(text, key, cursor, { multiWord, lookBackIndex, lastMatchPosition } = {
multiWord: false,
lookBackIndex: 0,
lastMatchPosition: null
}) {
let keyIndex = text.lastIndexOf(key, cursor - 1);
if (keyIndex === -1)
return;
if (keyIndex < lookBackIndex)
return;
if (multiWord) {
if (lastMatchPosition != null) {
if (lastMatchPosition === keyIndex)
return;
keyIndex = lastMatchPosition - key.length;
}
const charAfterKey = text[keyIndex + 1];
if (charAfterKey === ' ' && cursor >= keyIndex + key.length + 1)
return;
const newLineIndex = text.lastIndexOf('\n', cursor - 1);
if (newLineIndex > keyIndex)
return;
const dotIndex = text.lastIndexOf('.', cursor - 1);
if (dotIndex > keyIndex)
return;
}
else {
const spaceIndex = text.lastIndexOf(' ', cursor - 1);
if (spaceIndex > keyIndex)
return;
}
const pre = text[keyIndex - 1];
if (pre && !boundary.test(pre))
return;
const queryString = text.substring(keyIndex + key.length, cursor);
return {
text: queryString,
position: keyIndex + key.length
};
}
function textFieldSelectionPosition(field, index) {
const indexWithinRange = Math.max(0, index - 1);
return field.editor.getClientRectAtPosition(indexWithinRange);
}
class TrixEditorElementAdapter {
constructor(element) {
this.element = element;
}
focus(options) {
this.element.focus(options);
}
removeAttribute(name) {
this.element.removeAttribute(name);
}
setAttribute(name, value) {
this.element.setAttribute(name, value);
}
get selectionEnd() {
return this.editor.getPosition() + 1;
}
get value() {
return this.editor.getDocument().toString();
}
get editor() {
return this.element.editor;
}
}
function getJSONAttribute(element, key) {
try {
const value = element.getAttribute(key);
return JSON.parse(value || '{}');
}
catch (_a) {
return {};
}
}
function extractDataAttribute(dataset, key, prefix) {
const value = dataset[key];
const unprefixed = key.replace(prefix, '');
const firstCharacter = unprefixed[0];
const rest = unprefixed.substring(1);
const name = firstCharacter.toLowerCase() + rest;
return [name, value];
}
function buildTrixAttachment(elementOrOptions) {
const attribute = 'data-trix-attachment';
const prefix = 'trixAttachment';
if (elementOrOptions instanceof HTMLElement) {
const element = elementOrOptions;
const defaults = { content: element.innerHTML };
const options = getJSONAttribute(element, attribute);
const overrides = {};
const { dataset } = element;
for (const key in dataset) {
if (key.startsWith(prefix) && key !== prefix) {
const [name, value] = extractDataAttribute(dataset, key, prefix);
overrides[name] = value;
}
}
return new Trix.Attachment(Object.assign(Object.assign(Object.assign({}, defaults), options), overrides));
}
else if (elementOrOptions) {
const options = elementOrOptions;
return new Trix.Attachment(options);
}
else {
return null;
}
}
function assertTrixEditorElement(element) {
if (element && element.localName === 'trix-editor')
return;
throw new Error('Only trix-editor elements are supported');
}
function getFrameElementById(id) {
return document.querySelector(`turbo-frame#${id}:not([disabled])`);
}
function setSearchParam(element, src, name, value) {
const url = new URL(src || element.getAttribute('src') || '', element.baseURI);
url.searchParams.set(name, value);
element.setAttribute('src', url.toString());
return element.loaded || Promise.resolve();
}
const states = new WeakMap();
class TrixMentionsExpander {
constructor(expander) {
this.expander = expander;
this.combobox = null;
this.menu = null;
this.match = null;
this.justPasted = false;
this.lookBackIndex = 0;
this.oninput = this.onInput.bind(this);
this.onpaste = this.onPaste.bind(this);
this.onkeydown = this.onKeydown.bind(this);
this.oncommit = this.onCommit.bind(this);
this.onmousedown = this.onMousedown.bind(this);
this.onblur = this.onBlur.bind(this);
this.interactingWithList = false;
expander.addEventListener('paste', this.onpaste);
expander.addEventListener('input', this.oninput, { capture: true });
expander.addEventListener('keydown', this.onkeydown);
expander.addEventListener('focusout', this.onblur);
}
get input() {
const input = this.expander.querySelector('trix-editor');
assertTrixEditorElement(input);
return new TrixEditorElementAdapter(input);
}
destroy() {
this.expander.removeEventListener('paste', this.onpaste);
this.expander.removeEventListener('input', this.oninput, { capture: true });
this.expander.removeEventListener('keydown', this.onkeydown);
this.expander.removeEventListener('focusout', this.onblur);
}
dismissMenu() {
if (this.deactivate()) {
this.lookBackIndex = this.input.selectionEnd || this.lookBackIndex;
}
}
activate(match, menu) {
var _a, _b;
if (this.input.element !== document.activeElement &&
this.input.element !== ((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.shadowRoot) === null || _b === void 0 ? void 0 : _b.activeElement)) {
return;
}
this.deactivate();
this.menu = [menu, menu.isConnected];
if (!menu.id)
menu.id = `trix-mentions-${Math.floor(Math.random() * 100000).toString()}`;
if (menu.isConnected) {
menu.hidden = false;
}
else {
this.expander.append(menu);
}
this.combobox = new Combobox(this.input.element, menu);
this.input.setAttribute('role', 'combobox');
this.input.setAttribute('aria-multiline', 'false');
const { bottom, left } = textFieldSelectionPosition(this.input, match.position);
menu.style.top = `${bottom}px`;
menu.style.left = `${left}px`;
this.combobox.start();
menu.addEventListener('combobox-commit', this.oncommit);
menu.addEventListener('mousedown', this.onmousedown);
this.combobox.navigate(1);
}
deactivate() {
if (!this.menu || !this.combobox)
return false;
const [menu, isConnected] = this.menu;
this.menu = null;
menu.removeEventListener('combobox-commit', this.oncommit);
menu.removeEventListener('mousedown', this.onmousedown);
this.combobox.destroy();
this.combobox = null;
this.input.removeAttribute('aria-multiline');
this.input.setAttribute('role', 'textbox');
if (isConnected) {
menu.hidden = true;
}
else {
menu.remove();
}
return true;
}
onCommit({ target }) {
const item = target;
if (!(item instanceof HTMLElement))
return;
if (!this.combobox)
return;
const match = this.match;
if (!match)
return;
const selectionStart = match.position - match.key.length;
const selectionEnd = match.position + match.text.length;
const detail = { item, key: match.key, value: null };
const canceled = !this.expander.dispatchEvent(new CustomEvent('trix-mentions-value', { cancelable: true, detail }));
if (canceled)
return;
const attachment = buildTrixAttachment(detail.value || item);
if (!attachment)
return;
this.input.editor.setSelectedRange([selectionStart, selectionEnd]);
this.input.editor.deleteInDirection('backward');
this.input.editor.insertAttachment(attachment);
const cursor = this.input.selectionEnd;
this.deactivate();
this.input.focus({
preventScroll: true
});
this.lookBackIndex = cursor;
this.match = null;
}
onBlur({ target }) {
if (target !== this.input.element)
return;
if (this.interactingWithList) {
this.interactingWithList = false;
return;
}
this.deactivate();
}
onPaste({ target }) {
if (target !== this.input.element)
return;
this.justPasted = true;
}
async onInput({ target }) {
if (target !== this.input.element)
return;
if (this.justPasted) {
this.justPasted = false;
return;
}
const match = this.findMatch();
if (match) {
this.match = match;
const menu = await this.notifyProviders(match);
if (!this.match)
return;
if (menu) {
this.activate(match, menu);
}
else {
this.deactivate();
}
}
else {
this.match = null;
this.deactivate();
}
}
findMatch() {
const cursor = this.input.selectionEnd || 0;
const text = this.input.value.replace(/\n+$/, '');
if (cursor <= this.lookBackIndex) {
this.lookBackIndex = cursor - 1;
}
for (const { key, multiWord } of this.expander.keys) {
const found = query(text, key, cursor, {
multiWord,
lookBackIndex: this.lookBackIndex,
lastMatchPosition: this.match ? this.match.position : null
});
if (found) {
return { text: found.text, key, position: found.position };
}
}
}
async notifyProviders(match) {
const providers = [];
const provide = (result) => providers.push(result);
const canceled = !this.expander.dispatchEvent(new CustomEvent('trix-mentions-change', { cancelable: true, detail: { provide, text: match.text, key: match.key } }));
if (canceled)
return;
if (providers.length > 0) {
const all = await Promise.all(providers);
const fragments = all.filter(x => x.matched).map(x => x.fragment);
return fragments[0];
}
else {
return this.driveTurboFrame(match);
}
}
onMousedown() {
this.interactingWithList = true;
}
onKeydown(event) {
if (event.target !== this.input.element)
return;
if (event.key === 'Escape') {
this.match = null;
if (this.deactivate()) {
this.lookBackIndex = this.input.selectionEnd || this.lookBackIndex;
event.stopImmediatePropagation();
event.preventDefault();
}
}
}
async driveTurboFrame(match) {
const name = this.expander.name;
const frame = getFrameElementById(this.expander.getAttribute('data-turbo-frame'));
if (name && frame) {
await setSearchParam(frame, this.expander.src, name, match.text);
if (frame.childElementCount > 0) {
return frame;
}
}
}
}
class TrixMentionsElement extends HTMLElement {
get keys() {
const keysAttr = this.getAttribute('keys');
const keys = keysAttr ? keysAttr.split(' ') : [];
const multiWordAttr = this.getAttribute('multiword');
const multiWord = multiWordAttr ? multiWordAttr.split(' ') : [];
const globalMultiWord = multiWord.length === 0 && this.hasAttribute('multiword');
return keys.map(key => ({ key, multiWord: globalMultiWord || multiWord.includes(key) }));
}
get src() {
return this.getAttribute('src');
}
set src(value) {
if (value === null || typeof value === 'undefined') {
this.removeAttribute('src');
}
else {
this.setAttribute('src', value);
}
}
get name() {
return this.getAttribute('name');
}
set name(value) {
if (value === null || typeof value === 'undefined') {
this.removeAttribute('name');
}
else {
this.setAttribute('name', value);
}
}
connectedCallback() {
const state = new TrixMentionsExpander(this);
states.set(this, state);
}
disconnectedCallback() {
const state = states.get(this);
if (!state)
return;
state.destroy();
states.delete(this);
}
dismiss() {
const state = states.get(this);
if (!state)
return;
state.dismissMenu();
}
}
if (!window.customElements.get('trix-mentions')) {
window.TrixMentionsElement = TrixMentionsElement;
window.customElements.define('trix-mentions', TrixMentionsElement);
}
export default TrixMentionsElement;