| 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(/([^,;"|{}()\n]+)$/); |
| if (m) { |
| before = m[0] |
| .replace(/^\s+/, "") |
| .replace(/\s/g, "_") || null; |
| } 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); |
| } |
| value = this.#escapeParentheses(value); |
| |
| const afterCursor = this.helper.getAfterCursor(); |
| const shouldAddSeparator = !afterCursor.trim().startsWith(this.separator.trim()); |
| this.helper.insertAtCursor( |
| value + (shouldAddSeparator ? this.separator : ''), |
| -before.length, |
| wordInfo.caretOffset |
| ); |
| setTimeout(() => { |
| this.#update(); |
| }, 150); |
| }, |
| }, |
| 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"; |
| } |
|
|
| #escapeParentheses(text) { |
| return text.replace(/\(/g, '\\(').replace(/\)/g, '\\)'); |
| } |
|
|
| #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); |
| } |
| } |
| } |
|
|