|
import { $el } from "../../../../scripts/ui.js";
|
|
import { addStylesheet } from "./utils.js";
|
|
|
|
addStylesheet(import.meta.url);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const getCaretCoordinates = (function () {
|
|
|
|
|
|
|
|
|
|
var properties = [
|
|
"direction",
|
|
"boxSizing",
|
|
"width",
|
|
"height",
|
|
"overflowX",
|
|
"overflowY",
|
|
|
|
"borderTopWidth",
|
|
"borderRightWidth",
|
|
"borderBottomWidth",
|
|
"borderLeftWidth",
|
|
"borderStyle",
|
|
|
|
"paddingTop",
|
|
"paddingRight",
|
|
"paddingBottom",
|
|
"paddingLeft",
|
|
|
|
|
|
"fontStyle",
|
|
"fontVariant",
|
|
"fontWeight",
|
|
"fontStretch",
|
|
"fontSize",
|
|
"fontSizeAdjust",
|
|
"lineHeight",
|
|
"fontFamily",
|
|
|
|
"textAlign",
|
|
"textTransform",
|
|
"textIndent",
|
|
"textDecoration",
|
|
|
|
"letterSpacing",
|
|
"wordSpacing",
|
|
|
|
"tabSize",
|
|
"MozTabSize",
|
|
];
|
|
|
|
var isBrowser = typeof window !== "undefined";
|
|
var isFirefox = isBrowser && window.mozInnerScreenX != null;
|
|
|
|
return function getCaretCoordinates(element, position, options) {
|
|
if (!isBrowser) {
|
|
throw new Error("textarea-caret-position#getCaretCoordinates should only be called in a browser");
|
|
}
|
|
|
|
var debug = (options && options.debug) || false;
|
|
if (debug) {
|
|
var el = document.querySelector("#input-textarea-caret-position-mirror-div");
|
|
if (el) el.parentNode.removeChild(el);
|
|
}
|
|
|
|
|
|
var div = document.createElement("div");
|
|
div.id = "input-textarea-caret-position-mirror-div";
|
|
document.body.appendChild(div);
|
|
|
|
var style = div.style;
|
|
var computed = window.getComputedStyle ? window.getComputedStyle(element) : element.currentStyle;
|
|
var isInput = element.nodeName === "INPUT";
|
|
|
|
|
|
style.whiteSpace = "pre-wrap";
|
|
if (!isInput) style.wordWrap = "break-word";
|
|
|
|
|
|
style.position = "absolute";
|
|
if (!debug) style.visibility = "hidden";
|
|
|
|
|
|
properties.forEach(function (prop) {
|
|
if (isInput && prop === "lineHeight") {
|
|
|
|
if (computed.boxSizing === "border-box") {
|
|
var height = parseInt(computed.height);
|
|
var outerHeight =
|
|
parseInt(computed.paddingTop) +
|
|
parseInt(computed.paddingBottom) +
|
|
parseInt(computed.borderTopWidth) +
|
|
parseInt(computed.borderBottomWidth);
|
|
var targetHeight = outerHeight + parseInt(computed.lineHeight);
|
|
if (height > targetHeight) {
|
|
style.lineHeight = height - outerHeight + "px";
|
|
} else if (height === targetHeight) {
|
|
style.lineHeight = computed.lineHeight;
|
|
} else {
|
|
style.lineHeight = 0;
|
|
}
|
|
} else {
|
|
style.lineHeight = computed.height;
|
|
}
|
|
} else {
|
|
style[prop] = computed[prop];
|
|
}
|
|
});
|
|
|
|
if (isFirefox) {
|
|
|
|
if (element.scrollHeight > parseInt(computed.height)) style.overflowY = "scroll";
|
|
} else {
|
|
style.overflow = "hidden";
|
|
}
|
|
|
|
div.textContent = element.value.substring(0, position);
|
|
|
|
|
|
if (isInput) div.textContent = div.textContent.replace(/\s/g, "\u00a0");
|
|
|
|
var span = document.createElement("span");
|
|
|
|
|
|
|
|
|
|
|
|
span.textContent = element.value.substring(position) || ".";
|
|
div.appendChild(span);
|
|
|
|
var coordinates = {
|
|
top: span.offsetTop + parseInt(computed["borderTopWidth"]),
|
|
left: span.offsetLeft + parseInt(computed["borderLeftWidth"]),
|
|
height: parseInt(computed["lineHeight"]),
|
|
};
|
|
|
|
if (debug) {
|
|
span.style.backgroundColor = "#aaa";
|
|
} else {
|
|
document.body.removeChild(div);
|
|
}
|
|
|
|
return coordinates;
|
|
};
|
|
})();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const CHAR_CODE_ZERO = "0".charCodeAt(0);
|
|
const CHAR_CODE_NINE = "9".charCodeAt(0);
|
|
|
|
class TextAreaCaretHelper {
|
|
constructor(el, getScale) {
|
|
this.el = el;
|
|
this.getScale = getScale;
|
|
}
|
|
|
|
#calculateElementOffset() {
|
|
const rect = this.el.getBoundingClientRect();
|
|
const owner = this.el.ownerDocument;
|
|
if (owner == null) {
|
|
throw new Error("Given element does not belong to document");
|
|
}
|
|
const { defaultView, documentElement } = owner;
|
|
if (defaultView == null) {
|
|
throw new Error("Given element does not belong to window");
|
|
}
|
|
const offset = {
|
|
top: rect.top + defaultView.pageYOffset,
|
|
left: rect.left + defaultView.pageXOffset,
|
|
};
|
|
if (documentElement) {
|
|
offset.top -= documentElement.clientTop;
|
|
offset.left -= documentElement.clientLeft;
|
|
}
|
|
return offset;
|
|
}
|
|
|
|
#isDigit(charCode) {
|
|
return CHAR_CODE_ZERO <= charCode && charCode <= CHAR_CODE_NINE;
|
|
}
|
|
|
|
#getLineHeightPx() {
|
|
const computedStyle = getComputedStyle(this.el);
|
|
const lineHeight = computedStyle.lineHeight;
|
|
|
|
|
|
|
|
|
|
if (this.#isDigit(lineHeight.charCodeAt(0))) {
|
|
const floatLineHeight = parseFloat(lineHeight);
|
|
|
|
|
|
return this.#isDigit(lineHeight.charCodeAt(lineHeight.length - 1))
|
|
? floatLineHeight * parseFloat(computedStyle.fontSize)
|
|
: floatLineHeight;
|
|
}
|
|
|
|
|
|
return this.#calculateLineHeightPx(this.el.nodeName, computedStyle);
|
|
}
|
|
|
|
|
|
|
|
|
|
#calculateLineHeightPx(nodeName, computedStyle) {
|
|
const body = document.body;
|
|
if (!body) return 0;
|
|
|
|
const tempNode = document.createElement(nodeName);
|
|
tempNode.innerHTML = " ";
|
|
Object.assign(tempNode.style, {
|
|
fontSize: computedStyle.fontSize,
|
|
fontFamily: computedStyle.fontFamily,
|
|
padding: "0",
|
|
position: "absolute",
|
|
});
|
|
body.appendChild(tempNode);
|
|
|
|
|
|
if (tempNode instanceof HTMLTextAreaElement) {
|
|
tempNode.rows = 1;
|
|
}
|
|
|
|
|
|
const height = tempNode.offsetHeight;
|
|
body.removeChild(tempNode);
|
|
|
|
return height;
|
|
}
|
|
|
|
getCursorOffset() {
|
|
const scale = this.getScale();
|
|
const elOffset = this.#calculateElementOffset();
|
|
const elScroll = this.#getElScroll();
|
|
const cursorPosition = this.#getCursorPosition();
|
|
const lineHeight = this.#getLineHeightPx();
|
|
const top = elOffset.top - (elScroll.top * scale) + (cursorPosition.top + lineHeight) * scale;
|
|
const left = elOffset.left - elScroll.left + cursorPosition.left;
|
|
const clientTop = this.el.getBoundingClientRect().top;
|
|
if (this.el.dir !== "rtl") {
|
|
return { top, left, lineHeight, clientTop };
|
|
} else {
|
|
const right = document.documentElement ? document.documentElement.clientWidth - left : 0;
|
|
return { top, right, lineHeight, clientTop };
|
|
}
|
|
}
|
|
|
|
#getElScroll() {
|
|
return { top: this.el.scrollTop, left: this.el.scrollLeft };
|
|
}
|
|
|
|
#getCursorPosition() {
|
|
return getCaretCoordinates(this.el, this.el.selectionEnd);
|
|
}
|
|
|
|
getBeforeCursor() {
|
|
return this.el.selectionStart !== this.el.selectionEnd ? null : this.el.value.substring(0, this.el.selectionEnd);
|
|
}
|
|
|
|
getAfterCursor() {
|
|
return this.el.value.substring(this.el.selectionEnd);
|
|
}
|
|
|
|
insertAtCursor(value, offset, finalOffset) {
|
|
if (this.el.selectionStart != null) {
|
|
const startPos = this.el.selectionStart;
|
|
const endPos = this.el.selectionEnd;
|
|
|
|
|
|
this.el.selectionStart = this.el.selectionStart + offset;
|
|
|
|
|
|
|
|
let pasted = true;
|
|
try {
|
|
if (!document.execCommand("insertText", false, value)) {
|
|
pasted = false;
|
|
}
|
|
} catch (e) {
|
|
console.error("Error caught during execCommand:", e);
|
|
pasted = false;
|
|
}
|
|
|
|
if (!pasted) {
|
|
console.error(
|
|
"execCommand unsuccessful; not supported. Adding text manually, no undo support.");
|
|
textarea.setRangeText(modifiedText, this.el.selectionStart, this.el.selectionEnd, 'end');
|
|
}
|
|
|
|
this.el.selectionEnd = this.el.selectionStart = startPos + value.length + offset + (finalOffset ?? 0);
|
|
} else {
|
|
|
|
|
|
let pasted = true;
|
|
try {
|
|
if (!document.execCommand("insertText", false, value)) {
|
|
pasted = false;
|
|
}
|
|
} catch (e) {
|
|
console.error("Error caught during execCommand:", e);
|
|
pasted = false;
|
|
}
|
|
|
|
if (!pasted) {
|
|
console.error(
|
|
"execCommand unsuccessful; not supported. Adding text manually, no undo support.");
|
|
this.el.value += value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export class TextAreaAutoComplete {
|
|
static globalSeparator = "";
|
|
static enabled = true;
|
|
static insertOnTab = true;
|
|
static insertOnEnter = true;
|
|
static replacer = undefined;
|
|
static lorasEnabled = false;
|
|
static suggestionCount = 20;
|
|
|
|
|
|
static groups = {};
|
|
|
|
static globalGroups = new Set();
|
|
|
|
static globalWords = {};
|
|
|
|
static globalWordsExclLoras = {};
|
|
|
|
|
|
el;
|
|
|
|
|
|
overrideWords;
|
|
overrideSeparator = "";
|
|
|
|
get words() {
|
|
return this.overrideWords ?? TextAreaAutoComplete.globalWords;
|
|
}
|
|
|
|
get separator() {
|
|
return this.overrideSeparator ?? TextAreaAutoComplete.globalSeparator;
|
|
}
|
|
|
|
|
|
|
|
|
|
constructor(el, words = null, separator = null) {
|
|
this.el = el;
|
|
this.helper = new TextAreaCaretHelper(el, () => app.canvas.ds.scale);
|
|
this.dropdown = $el("div.pysssss-autocomplete");
|
|
this.overrideWords = words;
|
|
this.overrideSeparator = separator;
|
|
|
|
this.#setup();
|
|
}
|
|
|
|
#setup() {
|
|
this.el.addEventListener("keydown", this.#keyDown.bind(this));
|
|
this.el.addEventListener("keypress", this.#keyPress.bind(this));
|
|
this.el.addEventListener("keyup", this.#keyUp.bind(this));
|
|
this.el.addEventListener("click", this.#hide.bind(this));
|
|
this.el.addEventListener("blur", () => setTimeout(() => this.#hide(), 150));
|
|
}
|
|
|
|
|
|
|
|
|
|
#keyDown(e) {
|
|
if (!TextAreaAutoComplete.enabled) return;
|
|
|
|
if (this.dropdown.parentElement) {
|
|
|
|
switch (e.key) {
|
|
case "ArrowUp":
|
|
e.preventDefault();
|
|
if (this.selected.index) {
|
|
this.#setSelected(this.currentWords[this.selected.index - 1].wordInfo);
|
|
} else {
|
|
this.#setSelected(this.currentWords[this.currentWords.length - 1].wordInfo);
|
|
}
|
|
break;
|
|
case "ArrowDown":
|
|
e.preventDefault();
|
|
if (this.selected.index === this.currentWords.length - 1) {
|
|
this.#setSelected(this.currentWords[0].wordInfo);
|
|
} else {
|
|
this.#setSelected(this.currentWords[this.selected.index + 1].wordInfo);
|
|
}
|
|
break;
|
|
case "Tab":
|
|
if (TextAreaAutoComplete.insertOnTab) {
|
|
this.#insertItem();
|
|
e.preventDefault();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
#keyPress(e) {
|
|
if (!TextAreaAutoComplete.enabled) return;
|
|
if (this.dropdown.parentElement) {
|
|
|
|
switch (e.key) {
|
|
case "Enter":
|
|
if (!e.ctrlKey) {
|
|
if (TextAreaAutoComplete.insertOnEnter) {
|
|
this.#insertItem();
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!e.defaultPrevented) {
|
|
this.#update();
|
|
}
|
|
}
|
|
|
|
#keyUp(e) {
|
|
if (!TextAreaAutoComplete.enabled) return;
|
|
if (this.dropdown.parentElement) {
|
|
|
|
switch (e.key) {
|
|
case "Escape":
|
|
e.preventDefault();
|
|
this.#hide();
|
|
break;
|
|
}
|
|
} else if (e.key.length > 1 && e.key != "Delete" && e.key != "Backspace") {
|
|
return;
|
|
}
|
|
if (!e.defaultPrevented) {
|
|
this.#update();
|
|
}
|
|
}
|
|
|
|
#setSelected(item) {
|
|
if (this.selected) {
|
|
this.selected.el.classList.remove("pysssss-autocomplete-item--selected");
|
|
}
|
|
|
|
this.selected = item;
|
|
this.selected.el.classList.add("pysssss-autocomplete-item--selected");
|
|
}
|
|
|
|
#insertItem() {
|
|
if (!this.selected) return;
|
|
this.selected.el.click();
|
|
}
|
|
|
|
#getFilteredWords(term) {
|
|
term = term.toLocaleLowerCase();
|
|
|
|
const priorityMatches = [];
|
|
const prefixMatches = [];
|
|
const includesMatches = [];
|
|
for (const word of Object.keys(this.words)) {
|
|
const lowerWord = word.toLocaleLowerCase();
|
|
if (lowerWord === term) {
|
|
|
|
continue;
|
|
}
|
|
|
|
const pos = lowerWord.indexOf(term);
|
|
if (pos === -1) {
|
|
|
|
continue;
|
|
}
|
|
|
|
const wordInfo = this.words[word];
|
|
if (wordInfo.priority) {
|
|
priorityMatches.push({ pos, wordInfo });
|
|
} else if (pos) {
|
|
includesMatches.push({ pos, wordInfo });
|
|
} else {
|
|
prefixMatches.push({ pos, wordInfo });
|
|
}
|
|
}
|
|
|
|
priorityMatches.sort(
|
|
(a, b) =>
|
|
b.wordInfo.priority - a.wordInfo.priority ||
|
|
a.wordInfo.text.length - b.wordInfo.text.length ||
|
|
a.wordInfo.text.localeCompare(b.wordInfo.text)
|
|
);
|
|
|
|
const top = priorityMatches.length * 0.2;
|
|
return priorityMatches.slice(0, top).concat(prefixMatches, priorityMatches.slice(top), includesMatches).slice(0, TextAreaAutoComplete.suggestionCount);
|
|
}
|
|
|
|
#update() {
|
|
let before = this.helper.getBeforeCursor();
|
|
if (before?.length) {
|
|
const m = before.match(/([^\s|,|;|"]+)$/);
|
|
if (m) {
|
|
before = m[0];
|
|
} else {
|
|
before = null;
|
|
}
|
|
}
|
|
|
|
if (!before) {
|
|
this.#hide();
|
|
return;
|
|
}
|
|
|
|
this.currentWords = this.#getFilteredWords(before);
|
|
if (!this.currentWords.length) {
|
|
this.#hide();
|
|
return;
|
|
}
|
|
|
|
this.dropdown.style.display = "";
|
|
|
|
let hasSelected = false;
|
|
const items = this.currentWords.map(({ wordInfo, pos }, i) => {
|
|
const parts = [
|
|
$el("span", {
|
|
textContent: wordInfo.text.substr(0, pos),
|
|
}),
|
|
$el("span.pysssss-autocomplete-highlight", {
|
|
textContent: wordInfo.text.substr(pos, before.length),
|
|
}),
|
|
$el("span", {
|
|
textContent: wordInfo.text.substr(pos + before.length),
|
|
}),
|
|
];
|
|
|
|
if (wordInfo.hint) {
|
|
parts.push(
|
|
$el("span.pysssss-autocomplete-pill", {
|
|
textContent: wordInfo.hint,
|
|
})
|
|
);
|
|
}
|
|
|
|
if (wordInfo.priority) {
|
|
parts.push(
|
|
$el("span.pysssss-autocomplete-pill", {
|
|
textContent: wordInfo.priority,
|
|
})
|
|
);
|
|
}
|
|
|
|
if (wordInfo.value && wordInfo.text !== wordInfo.value && wordInfo.showValue !== false) {
|
|
parts.push(
|
|
$el("span.pysssss-autocomplete-pill", {
|
|
textContent: wordInfo.value,
|
|
})
|
|
);
|
|
}
|
|
|
|
if (wordInfo.info) {
|
|
parts.push(
|
|
$el("a.pysssss-autocomplete-item-info", {
|
|
textContent: "ℹ️",
|
|
title: "View info...",
|
|
onclick: (e) => {
|
|
e.stopPropagation();
|
|
wordInfo.info();
|
|
e.preventDefault();
|
|
},
|
|
})
|
|
);
|
|
}
|
|
const item = $el(
|
|
"div.pysssss-autocomplete-item",
|
|
{
|
|
onclick: () => {
|
|
this.el.focus();
|
|
let value = wordInfo.value ?? wordInfo.text;
|
|
const use_replacer = wordInfo.use_replacer ?? true;
|
|
if (TextAreaAutoComplete.replacer && use_replacer) {
|
|
value = TextAreaAutoComplete.replacer(value);
|
|
}
|
|
this.helper.insertAtCursor(value + this.separator, -before.length, wordInfo.caretOffset);
|
|
setTimeout(() => {
|
|
this.#update();
|
|
}, 150);
|
|
},
|
|
onmousemove: () => {
|
|
this.#setSelected(wordInfo);
|
|
},
|
|
},
|
|
parts
|
|
);
|
|
|
|
if (wordInfo === this.selected) {
|
|
hasSelected = true;
|
|
}
|
|
|
|
wordInfo.index = i;
|
|
wordInfo.el = item;
|
|
|
|
return item;
|
|
});
|
|
|
|
this.#setSelected(hasSelected ? this.selected : this.currentWords[0].wordInfo);
|
|
this.dropdown.replaceChildren(...items);
|
|
|
|
if (!this.dropdown.parentElement) {
|
|
document.body.append(this.dropdown);
|
|
}
|
|
|
|
const position = this.helper.getCursorOffset();
|
|
this.dropdown.style.left = (position.left ?? 0) + "px";
|
|
this.dropdown.style.top = (position.top ?? 0) + "px";
|
|
this.dropdown.style.maxHeight = (window.innerHeight - position.top) + "px";
|
|
}
|
|
|
|
#hide() {
|
|
this.selected = null;
|
|
this.dropdown.remove();
|
|
}
|
|
|
|
static updateWords(id, words, addGlobal = true) {
|
|
const isUpdate = id in TextAreaAutoComplete.groups;
|
|
TextAreaAutoComplete.groups[id] = words;
|
|
if (addGlobal) {
|
|
TextAreaAutoComplete.globalGroups.add(id);
|
|
}
|
|
|
|
if (isUpdate) {
|
|
|
|
TextAreaAutoComplete.globalWords = Object.assign(
|
|
{},
|
|
...Object.keys(TextAreaAutoComplete.groups)
|
|
.filter((k) => TextAreaAutoComplete.globalGroups.has(k))
|
|
.map((k) => TextAreaAutoComplete.groups[k])
|
|
);
|
|
} else if (addGlobal) {
|
|
|
|
Object.assign(TextAreaAutoComplete.globalWords, words);
|
|
}
|
|
}
|
|
}
|
|
|