UNPKG

@github/text-expander-element

Version:

Activates a suggestion menu to expand text snippets as you type.

230 lines (229 loc) 8.63 kB
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(); } }