@github/text-expander-element
Version:
Activates a suggestion menu to expand text snippets as you type.
230 lines (229 loc) • 8.63 kB
JavaScript
import Combobox from '@github/combobox-nav';
import query from './query';
import { InputRange } from 'dom-input-range';
const states = new WeakMap();
class TextExpander {
constructor(expander, input) {
this.expander = expander;
this.input = input;
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;
input.addEventListener('paste', this.onpaste);
input.addEventListener('input', this.oninput);
input.addEventListener('keydown', this.onkeydown);
input.addEventListener('blur', this.onblur);
}
destroy() {
this.input.removeEventListener('paste', this.onpaste);
this.input.removeEventListener('input', this.oninput);
this.input.removeEventListener('keydown', this.onkeydown);
this.input.removeEventListener('blur', this.onblur);
}
dismissMenu() {
if (this.deactivate()) {
this.lookBackIndex = this.input.selectionEnd || this.lookBackIndex;
}
}
activate(match, menu) {
var _a, _b;
if (this.input !== document.activeElement && this.input !== ((_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;
if (!menu.id)
menu.id = `text-expander-${Math.floor(Math.random() * 100000).toString()}`;
this.expander.append(menu);
this.combobox = new Combobox(this.input, menu);
this.expander.dispatchEvent(new Event('text-expander-activate'));
this.positionMenu(menu, match.position);
this.combobox.start();
menu.addEventListener('combobox-commit', this.oncommit);
menu.addEventListener('mousedown', this.onmousedown);
this.combobox.navigate(1);
}
positionMenu(menu, position) {
const caretRect = new InputRange(this.input, position).getBoundingClientRect();
const targetPosition = { left: caretRect.left, top: caretRect.top + caretRect.height };
const currentPosition = menu.getBoundingClientRect();
const delta = {
left: targetPosition.left - currentPosition.left,
top: targetPosition.top - currentPosition.top
};
if (delta.left !== 0 || delta.top !== 0) {
const currentStyle = getComputedStyle(menu);
menu.style.left = currentStyle.left ? `calc(${currentStyle.left} + ${delta.left}px)` : `${delta.left}px`;
menu.style.top = currentStyle.top ? `calc(${currentStyle.top} + ${delta.top}px)` : `${delta.top}px`;
}
}
deactivate() {
const menu = this.menu;
if (!menu || !this.combobox)
return false;
this.expander.dispatchEvent(new Event('text-expander-deactivate'));
this.menu = null;
menu.removeEventListener('combobox-commit', this.oncommit);
menu.removeEventListener('mousedown', this.onmousedown);
this.combobox.destroy();
this.combobox = null;
menu.remove();
return true;
}
onCommit({ target }) {
var _a;
const item = target;
if (!(item instanceof HTMLElement))
return;
if (!this.combobox)
return;
const match = this.match;
if (!match)
return;
const beginning = this.input.value.substring(0, match.position - match.key.length);
const remaining = this.input.value.substring(match.position + match.text.length);
const detail = { item, key: match.key, value: null, continue: false };
const canceled = !this.expander.dispatchEvent(new CustomEvent('text-expander-value', { cancelable: true, detail }));
if (canceled)
return;
if (!detail.value)
return;
let suffix = (_a = this.expander.getAttribute('suffix')) !== null && _a !== void 0 ? _a : ' ';
if (detail.continue) {
suffix = '';
}
const value = `${detail.value}${suffix}`;
this.input.value = beginning + value + remaining;
const cursor = beginning.length + value.length;
this.deactivate();
this.input.focus({
preventScroll: true
});
this.input.selectionStart = cursor;
this.input.selectionEnd = cursor;
if (!detail.continue) {
this.lookBackIndex = cursor;
this.match = null;
}
this.expander.dispatchEvent(new CustomEvent('text-expander-committed', { cancelable: false, detail: { input: this.input } }));
}
onBlur() {
if (this.interactingWithList) {
this.interactingWithList = false;
return;
}
this.deactivate();
}
onPaste() {
this.justPasted = true;
}
async onInput() {
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;
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 changeEvent = new CustomEvent('text-expander-change', {
cancelable: true,
detail: { provide, text: match.text, key: match.key }
});
const canceled = !this.expander.dispatchEvent(changeEvent);
if (canceled)
return;
const all = await Promise.all(providers);
const fragments = all.filter(x => x.matched).map(x => x.fragment);
return fragments[0];
}
onMousedown() {
this.interactingWithList = true;
}
onKeydown(event) {
if (event.key === 'Escape') {
this.match = null;
if (this.deactivate()) {
this.lookBackIndex = this.input.selectionEnd || this.lookBackIndex;
event.stopImmediatePropagation();
event.preventDefault();
}
}
}
}
export default class TextExpanderElement 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) }));
}
set keys(value) {
this.setAttribute('keys', value);
}
connectedCallback() {
const input = this.querySelector('input[type="text"], textarea');
if (!(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement))
return;
const state = new TextExpander(this, input);
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();
}
}