@github/text-expander-element
Version:
Activates a suggestion menu to expand text snippets as you type.
820 lines (813 loc) • 33.2 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
};
}
class InputStyleCloneUpdateEvent extends Event {
constructor() {
super("update");
}
}
const CloneRegistry = new WeakMap();
/**
* Creates an element that exactly matches an input pixel-for-pixel and automatically stays in sync with it. This
* is a non-interactive overlay on to the input and can be used to affect the visual appearance of the input
* without modifying its behavior. The clone element is hidden by default.
*
* This lower level API powers the `InputRange` but provides more advanced functionality including event updates.
*
* Emits `update` events whenever anything is recalculated: when the layout changes, when the user scrolls, when the
* input is updated, etc. This event may be emitted more than once per change.
*
* @note There may be cases in which the clone cannot observe changes to the input and fails to automatically update.
* For example, if the `value` property on the input is written to directly, no `input` event is emitted by the input
* and the clone does not automatically update. In these cases, `forceUpdate` can be used to manually trigger an update.
*/
// PRIOR ART: This approach was adapted from the following MIT-licensed sources:
// - primer/react (Copyright (c) 2018 GitHub, Inc.): https://github.com/primer/react/blob/a0db832302702b869aa22b0c4049ad9305ef631f/src/drafts/utils/character-coordinates.ts
// - component/textarea-caret-position (Copyright (c) 2015 Jonathan Ong me@jongleberry.com): https://github.com/component/textarea-caret-position/blob/b5db7a7e47dd149c2a66276183c69234e4dabe30/index.js
// - koddsson/textarea-caret-position (Copyright (c) 2015 Jonathan Ong me@jongleberry.com): https://github.com/koddsson/textarea-caret-position/blob/eba40ec8488eed4d77815f109af22e1d9c0751d3/index.js
class InputStyleClone extends EventTarget {
#styleObserver = new MutationObserver(() => this.#updateStyles());
#resizeObserver = new ResizeObserver(() => this.#requestUpdateLayout());
// This class is unique in that it will prevent itself from getting garbage collected because of the subscribed
// observers (if never detached). Because of this, we want to avoid preventing the existence of this class from also
// preventing the garbage collection of the associated input. This also allows us to automatically detach if the
// input gets collected.
#inputRef;
#container = document.createElement("div");
#cloneElement = document.createElement("div");
/**
* Get the clone for an input, reusing an existing one if available. This avoids creating unecessary clones, which
* have a performance cost due to their high-frequency event-based updates. Because these elements are shared, they
* should be mutated with caution. If you're planning to mutate the clone, consider constructing a new one instead.
*
* Upon initial creation the clone element will automatically be inserted into the DOM and begin observing the
* linked input.
* @param input The target input to clone.
*/
static for(input) {
let clone = CloneRegistry.get(input);
if (!clone) {
clone = new InputStyleClone(input);
CloneRegistry.set(input, clone);
}
return clone;
}
/**
* Connect this instance to a target input element and insert this instance into the DOM in the correct location.
*
* NOTE: calling the static `for` method is usually preferable as it will reuse an existing clone if available.
* However, if reusing clones is problematic (ie, if the clone needs to be mutated), a clone can be constructed
* directly with `new InputStyleClone(target)`.
*/
constructor(input) {
super();
this.#inputRef = new WeakRef(input);
// We want position:absolute so it doesn't take space in the layout, but that doesn't work with display:table-cell
// used in the HTMLInputElement approach. So we need a wrapper.
this.#container.style.position = "absolute";
this.#container.style.pointerEvents = "none";
this.#container.setAttribute("aria-hidden", "true");
this.#container.appendChild(this.#cloneElement);
this.#cloneElement.style.pointerEvents = "none";
this.#cloneElement.style.userSelect = "none";
this.#cloneElement.style.overflow = "hidden";
this.#cloneElement.style.display = "block";
// Important not to use display:none which would not render the content at all
this.#cloneElement.style.visibility = "hidden";
if (input instanceof HTMLTextAreaElement) {
this.#cloneElement.style.whiteSpace = "pre-wrap";
this.#cloneElement.style.wordWrap = "break-word";
}
else {
this.#cloneElement.style.whiteSpace = "nowrap";
// text in single-line inputs is vertically centered
this.#cloneElement.style.display = "table-cell";
this.#cloneElement.style.verticalAlign = "middle";
}
input.after(this.#container);
this.#updateStyles();
this.#updateText();
this.#styleObserver.observe(input, {
attributeFilter: [
"style",
"dir", // users can right-click in some browsers to change the text direction dynamically
],
});
this.#resizeObserver.observe(input);
document.addEventListener("scroll", this.#onDocumentScrollOrResize, { capture: true });
window.addEventListener("resize", this.#onDocumentScrollOrResize, { capture: true });
// capture so this happens first, so other things can respond to `input` events after this data updates
input.addEventListener("input", this.#onInput, { capture: true });
}
/** Get the clone element. */
get element() {
return this.#cloneElement;
}
/**
* Force a recalculation. Will emit an `update` event. This is typically not needed unless the input has changed in
* an unobservable way, eg by directly writing to the `value` property.
*/
forceUpdate() {
this.#updateStyles();
this.#updateText();
}
disconnect() {
this.#container?.remove();
this.#styleObserver.disconnect();
this.#resizeObserver.disconnect();
document.removeEventListener("scroll", this.#onDocumentScrollOrResize, { capture: true });
window.removeEventListener("resize", this.#onDocumentScrollOrResize, { capture: true });
// Can't use `usingInput` here since that could infinitely recurse
const input = this.#input;
if (input) {
input.removeEventListener("input", this.#onInput, { capture: true });
CloneRegistry.delete(input);
}
}
// --- private ---
get #input() {
return this.#inputRef?.deref();
}
/** Perform `fn` using the `input` if it is still available. If not, clean up the clone instead. */
#usingInput(fn) {
const input = this.#input;
if (!input)
return this.disconnect();
return fn(input);
}
/** Current relative x-adjustment in pixels, executed via CSS transform. */
#xOffset = 0;
/** Current relative y-adjustment in pixels, executed via CSS transform. */
#yOffset = 0;
/**
* Update only geometric properties without recalculating styles. Typically call `#requestUpdateLayout` instead to
* only update once per animation frame.
*/
#updateLayout() {
// This runs often, so keep it as fast as possible! Avoid all unecessary updates.
this.#usingInput((input) => {
const inputStyle = window.getComputedStyle(input);
this.#cloneElement.style.height = inputStyle.height;
this.#cloneElement.style.width = inputStyle.width;
// Immediately re-adjust for browser inconsistencies in scrollbar handling, if necessary
if (input.clientHeight !== this.#cloneElement.clientHeight)
this.#cloneElement.style.height = `calc(${inputStyle.height} + ${input.clientHeight - this.#cloneElement.clientHeight}px)`;
if (input.clientWidth !== this.#cloneElement.clientWidth)
this.#cloneElement.style.width = `calc(${inputStyle.width} + ${input.clientWidth - this.#cloneElement.clientWidth}px)`;
// Position on top of the input
const inputRect = input.getBoundingClientRect();
const cloneRect = this.#cloneElement.getBoundingClientRect();
this.#xOffset = this.#xOffset + inputRect.left - cloneRect.left;
this.#yOffset = this.#yOffset + inputRect.top - cloneRect.top;
this.#cloneElement.style.transform = `translate(${this.#xOffset}px, ${this.#yOffset}px)`;
this.#cloneElement.scrollTop = input.scrollTop;
this.#cloneElement.scrollLeft = input.scrollLeft;
this.dispatchEvent(new InputStyleCloneUpdateEvent());
});
}
#isLayoutUpdating = false;
/** Request a layout update. Will only happen once per animation frame, to avoid unecessary updates. */
#requestUpdateLayout() {
if (this.#isLayoutUpdating)
return;
this.#isLayoutUpdating = true;
requestAnimationFrame(() => {
this.#updateLayout();
this.#isLayoutUpdating = false;
});
}
/** Update the styles of the clone based on the styles of the input, then request a layout update. */
#updateStyles() {
this.#usingInput((input) => {
const inputStyle = window.getComputedStyle(input);
for (const prop of propertiesToCopy)
this.#cloneElement.style[prop] = inputStyle[prop];
this.#requestUpdateLayout();
});
}
/**
* Update the text content of the clone based on the text content of the input. Triggers a layout update in case the
* text update caused scrolling.
*/
#updateText() {
this.#usingInput((input) => {
this.#cloneElement.textContent = input.value;
// This is often unecessary on a pure text update, but text updates could potentially cause layout updates like
// scrolling or resizing. And we run the update on _every frame_ when scrolling, so this isn't that expensive.
// We don't requestUpdateLayout here because this one should happen synchronously, so that clients can react
// within their own `input` event handlers.
this.#updateLayout();
});
}
#onInput = () => this.#updateText();
#onDocumentScrollOrResize = (event) => {
this.#usingInput((input) => {
if (event.target === document ||
event.target === window ||
(event.target instanceof Node && event.target.contains(input)))
this.#requestUpdateLayout();
});
};
}
// Note that some browsers, such as Firefox, do not concatenate properties
// into their shorthand (e.g. padding-top, padding-bottom etc. -> padding),
// so we have to list every single property explicitly.
const propertiesToCopy = [
// RTL / vertical writing modes support:
"direction",
"writingMode",
"unicodeBidi",
"textOrientation",
"boxSizing",
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"borderStyle",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
// https://developer.mozilla.org/en-US/docs/Web/CSS/font
"fontStyle",
"fontVariant",
"fontWeight",
"fontStretch",
"fontSize",
"fontSizeAdjust",
"lineHeight",
"fontFamily",
"textAlign",
"textTransform",
"textIndent",
"textDecoration",
"letterSpacing",
"wordSpacing",
"tabSize",
"MozTabSize",
];
class InputRange {
#inputElement;
#startOffset;
#endOffset;
/**
* Construct a new `InputRange`.
* @param element The target input element that contains the content for the range.
* @param startOffset The inclusive 0-based start index for the range. Will be adjusted to fit in the input contents.
* @param endOffset The exclusive 0-based end index for the range. Will be adjusted to fit in the input contents.
*/
constructor(element, startOffset = 0, endOffset = startOffset) {
this.#inputElement = element;
this.#startOffset = startOffset;
this.#endOffset = endOffset;
}
/**
* Create a new range from the current user selection. If the input is not focused, the range will just be the start
* of the input (offsets `0` to `0`).
*
* This can be used to get the caret coordinates: if the resulting range is `collapsed`, the location of the
* `getBoundingClientRect` will be the location of the caret caret (note, however, that the width will be `0` in
* this case).
*/
static fromSelection(input) {
const { selectionStart, selectionEnd } = input;
return new InputRange(input, selectionStart ?? undefined, selectionEnd ?? undefined);
}
/** Returns true if the start is equal to the end of this range. */
get collapsed() {
return this.startOffset === this.endOffset;
}
/** Always returns the containing input element. */
get commonAncestorContainer() {
return this.#inputElement;
}
/** Always returns the containing input element. */
get endContainer() {
return this.#inputElement;
}
/** Always returns the containing input element. */
get startContainer() {
return this.#inputElement;
}
get startOffset() {
return this.#startOffset;
}
get endOffset() {
return this.#endOffset;
}
/** Update the inclusive start offset. Will be adjusted to fit within the content size. */
setStartOffset(offset) {
this.#startOffset = this.#clampOffset(offset);
}
/** Update the exclusive end offset. Will be adjusted to fit within the content size. */
setEndOffset(offset) {
this.#endOffset = this.#clampOffset(offset);
}
/**
* Collapse this range to one side.
* @param toStart If `true`, will collapse to the start side. Otherwise, will collapse to the end.
*/
collapse(toStart = false) {
if (toStart)
this.setEndOffset(this.startOffset);
else
this.setStartOffset(this.endOffset);
}
/** Returns a `DocumentFragment` containing a new `Text` node containing the content in the range. */
cloneContents() {
return this.#createCloneRange().cloneContents();
}
/** Create a copy of this range. */
cloneRange() {
return new InputRange(this.#inputElement, this.startOffset, this.endOffset);
}
/**
* Obtain one rect that contains the entire contents of the range. If the range spans multiple lines, this box will
* contain all pieces of the range but may also contain some space outside the range.
* @see https://iansan5653.github.io/dom-input-range/demos/playground/
*/
getBoundingClientRect() {
return this.#createCloneRange().getBoundingClientRect();
}
/**
* Obtain the rects that contain contents of this range. If the range spans multiple lines, there will be multiple
* bounding boxes. These boxes can be used, for example, to draw a highlight over the range.
* @see https://iansan5653.github.io/dom-input-range/demos/playground/
*/
getClientRects() {
return this.#createCloneRange().getClientRects();
}
/** Get the contents of the range as a string. */
toString() {
return this.#createCloneRange().toString();
}
/**
* Get the underlying `InputStyleClone` instance powering these calculations. This can be used to listen for
* updates to trigger layout recalculation.
*/
getStyleClone() {
return this.#styleClone;
}
// --- private ---
get #styleClone() {
return InputStyleClone.for(this.#inputElement);
}
get #cloneElement() {
return this.#styleClone;
}
#clampOffset(offset) {
return Math.max(0, Math.min(offset, this.#inputElement.value.length));
}
#createCloneRange() {
// It's tempting to create a single Range and reuse it across the lifetime of the class. However, this wouldn't be
// accurate because the contents of the input can change and the contents of the range would become stale. So we
// must create a new range every time we need it.
const range = document.createRange();
const textNode = this.#cloneElement.element.childNodes[0];
if (textNode) {
range.setStart(textNode, this.startOffset);
range.setEnd(textNode, this.endOffset);
}
return 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();
}
}
}
}
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();
}
}
if (!window.customElements.get('text-expander')) {
window.TextExpanderElement = TextExpanderElement;
window.customElements.define('text-expander', TextExpanderElement);
}
export { TextExpanderElement as default };