Blame Raw
· · · 307 lines (12.0 KB)
0 contributors
1/**
2 * Macro autocomplete for free text inputs (textareas and input fields).
3 * Provides macro autocomplete when typing `{{` in marked text inputs.
4 *
5 * This module uses shared utilities from MacroAutoCompleteHelper.js to ensure
6 * consistent behavior with the slash command macro autocomplete.
7 *
8 * Usage:
9 * - Mark a textarea/input with `data-macros` or `data-macros="true"` attribute
10 * - Call `initMacroAutoComplete()` to initialize all marked elements
11 * - Dynamically added elements are automatically initialized via MutationObserver
12 */
13
14import { power_user } from '../power-user.js';
15import { AutoComplete, AUTOCOMPLETE_STATE } from './AutoComplete.js';
16import { findMacroAtCursor, findUnclosedScopes, getMacroAutoCompleteAt } from './MacroAutoCompleteHelper.js';
17
18/** Custom attribute name used to mark elements that support macro autocomplete */
19export const MACRO_AUTOCOMPLETE_ATTRIBUTE = 'data-macros';
20
21/** Attribute to control autocomplete visibility: 'always' (force show) or 'hide' (never show) */
22export const MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE = 'data-macros-autocomplete';
23
24/** Generic attribute to control autocomplete popup style/size (used by AutoComplete) */
25export const MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE = 'data-macros-autocomplete-style';
26
27/**
28 * @readonly
29 * @enum {string}
30 */
31export const MACRO_AUTOCOMPLETE_MODE = Object.freeze({
32 /** Default behavior: respects global setting showInAllMacroFields */
33 DEFAULT: 'default',
34 /** Always show autocomplete in this field (expanded editors, prompt manager) */
35 ALWAYS: 'always',
36 /** Never show autocomplete in this field */
37 HIDE: 'hide',
38});
39
40/**
41 * @readonly
42 * @enum {string}
43 */
44export const MACRO_AUTOCOMPLETE_STYLE = Object.freeze({
45 /** Small popup (33vw, max 700px) for inline fields */
46 SMALL: 'small',
47 /** Expanded popup (default chat width) for expanded editors */
48 EXPANDED: 'expanded',
49});
50
51/** @type {WeakSet<HTMLElement>} Track initialized elements to avoid double-init */
52const initializedElements = new WeakSet();
53
54/** @type {WeakMap<HTMLElement, AutoComplete>} Map elements to their autocomplete instances */
55const elementAutoCompleteMap = new WeakMap();
56
57/**
58 * Checks if the cursor is positioned where macro autocomplete should activate.
59 * Activates when:
60 * - Cursor is right after typing `{{`
61 * - Cursor is inside a macro `{{...}}`
62 * - Cursor is in scoped content of an unclosed scoped macro (e.g., after `{{setvar myvar}}`)
63 *
64 * @param {string} text - The full text content.
65 * @param {number} cursorPos - The cursor position.
66 * @param {Object} [options={}] - Additional options.
67 * @param {boolean} [options.isForced=false] - Whether this is a forced activation (e.g., Ctrl+Space).
68 * @param {MACRO_AUTOCOMPLETE_MODE} [options.autocompleteMode=MACRO_AUTOCOMPLETE_MODE.DEFAULT] - The autocomplete mode.
69 * @returns {boolean}
70 */
71function shouldActivateMacroAutocomplete(text, cursorPos, { isForced = false, autocompleteMode = MACRO_AUTOCOMPLETE_MODE.DEFAULT } = {}) {
72 // If mode is 'hide', never show autocomplete
73 if (autocompleteMode === MACRO_AUTOCOMPLETE_MODE.HIDE) {
74 return false;
75 }
76
77 // Check if autocomplete is enabled at all
78 if (power_user.stscript.autocomplete.state === AUTOCOMPLETE_STATE.DISABLED) {
79 return false;
80 }
81
82 // Determine if we should show normally based on mode and settings
83 // ALWAYS mode: always show, DEFAULT mode: respect global setting
84 const alwaysShow = autocompleteMode === MACRO_AUTOCOMPLETE_MODE.ALWAYS;
85 const shouldShowNormally = isForced || alwaysShow || power_user.stscript.autocomplete.showInAllMacroFields;
86
87 // Whether setting says autocomplete should only activate after typing {{ and two characters after that
88 // Ctrl+Space (isForced) overrides this restriction
89 const onlyAfter2 = !isForced && power_user.stscript.autocomplete.state === AUTOCOMPLETE_STATE.MIN_LENGTH;
90
91 // Check if we're right after {{ (just typed the second brace)
92 if (cursorPos >= 2 && text.slice(cursorPos - 2, cursorPos) === '{{') {
93 return shouldShowNormally && !onlyAfter2;
94 }
95
96 // Check if we're inside a macro
97 const macro = findMacroAtCursor(text, cursorPos);
98 if (macro !== null) {
99 if (!shouldShowNormally) return false;
100 return !onlyAfter2 || (macro.content.trim()).length >= 2;
101 }
102
103 // Check if we're in scoped content of an unclosed scoped macro
104 const textUpToCursor = text.slice(0, cursorPos);
105 const unclosedScopes = findUnclosedScopes(textUpToCursor);
106 return shouldShowNormally && unclosedScopes.length > 0;
107}
108
109/**
110 * Sets up macro autocomplete for a text input element.
111 * The autocomplete will trigger when typing `{{` inside the element.
112 *
113 * @param {HTMLTextAreaElement|HTMLInputElement} textarea - The input element.
114 * @param {Object} [options={}] - Options for the autocomplete.
115 * @param {MACRO_AUTOCOMPLETE_MODE} [options.autocompleteMode=MACRO_AUTOCOMPLETE_MODE.DEFAULT] - The autocomplete mode.
116 * @param {MACRO_AUTOCOMPLETE_STYLE} [options.autocompleteStyle=MACRO_AUTOCOMPLETE_STYLE.SMALL] - The autocomplete style.
117 * @returns {AutoComplete} The autocomplete instance.
118 */
119export function setMacroAutoComplete(textarea, { autocompleteMode = MACRO_AUTOCOMPLETE_MODE.DEFAULT, autocompleteStyle = MACRO_AUTOCOMPLETE_STYLE.SMALL } = {}) {
120 const ac = new AutoComplete(
121 textarea,
122 () => shouldActivateMacroAutocomplete(ac.text, textarea.selectionStart, { isForced: ac.isShowForced, autocompleteMode }),
123 (text, index) => getMacroAutoCompleteAt(text, index, { isForced: ac.isShowForced }),
124 true, // isFloating - always use floating mode for free text macro autocomplete
125 );
126
127 // Set the style via data attribute for CSS targeting
128 ac.domWrap.dataset.macrosAutocompleteStyle = autocompleteStyle;
129 ac.detailsWrap.dataset.macrosAutocompleteStyle = autocompleteStyle;
130
131 elementAutoCompleteMap.set(textarea, ac);
132 return ac;
133}
134
135/**
136 * Gets the autocomplete mode from an element's data-macros-autocomplete attribute.
137 *
138 * @param {Element} element - The element to check.
139 * @returns {MACRO_AUTOCOMPLETE_MODE} The mode ('default', 'always', 'hide').
140 */
141function getAutocompleteMode(element) {
142 if (!element.hasAttribute(MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE)) {
143 return MACRO_AUTOCOMPLETE_MODE.DEFAULT;
144 }
145 const value = element.getAttribute(MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE);
146 if (value === MACRO_AUTOCOMPLETE_MODE.ALWAYS || value === MACRO_AUTOCOMPLETE_MODE.HIDE) {
147 return value;
148 }
149 return MACRO_AUTOCOMPLETE_MODE.DEFAULT;
150}
151
152/**
153 * Gets the autocomplete style from an element's data-autocomplete-style attribute.
154 *
155 * @param {Element} element - The element to check.
156 * @returns {MACRO_AUTOCOMPLETE_STYLE} The style ('expanded', 'small').
157 */
158function getAutocompleteStyle(element) {
159 if (!element.hasAttribute(MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE)) {
160 return MACRO_AUTOCOMPLETE_STYLE.SMALL; // Default for macro autocomplete is small
161 }
162 const value = element.getAttribute(MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE);
163 if (value === MACRO_AUTOCOMPLETE_STYLE.SMALL || value === MACRO_AUTOCOMPLETE_STYLE.EXPANDED) {
164 return value;
165 }
166 return MACRO_AUTOCOMPLETE_STYLE.EXPANDED;
167}
168
169/**
170 * Initializes macro autocomplete on a single element if not already initialized.
171 *
172 * @param {HTMLTextAreaElement|HTMLInputElement} element - The element to initialize.
173 * @returns {AutoComplete|null} The autocomplete instance, or null if already initialized.
174 */
175function initializeElement(element) {
176 if (initializedElements.has(element)) {
177 return null;
178 }
179
180 if (!(element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement)) {
181 return null;
182 }
183
184 const autocompleteMode = getAutocompleteMode(element);
185 const autocompleteStyle = getAutocompleteStyle(element);
186 initializedElements.add(element);
187 return setMacroAutoComplete(element, { autocompleteMode, autocompleteStyle });
188}
189
190/**
191 * Checks if an element has the macro autocomplete attribute enabled.
192 * Supports both `data-macros` (presence) and `data-macros="true"`.
193 *
194 * @param {Element} element - The element to check.
195 * @returns {boolean}
196 */
197function hasMacroAttribute(element) {
198 if (!element.hasAttribute(MACRO_AUTOCOMPLETE_ATTRIBUTE)) {
199 return false;
200 }
201 const value = element.getAttribute(MACRO_AUTOCOMPLETE_ATTRIBUTE);
202 // Attribute present with no value, empty string, or "true" all count as enabled
203 return value === null || value === '' || value === 'true';
204}
205
206/**
207 * Handles node changes from MutationObserver - checks for macro autocomplete attribute.
208 *
209 * @param {Node} node - The node to check.
210 */
211function handleNodeChange(node) {
212 if (node.nodeType !== Node.ELEMENT_NODE || !(node instanceof Element)) {
213 return;
214 }
215
216 // Check if this element has the macro autocomplete attribute
217 if (hasMacroAttribute(node)) {
218 if (node instanceof HTMLTextAreaElement || node instanceof HTMLInputElement) {
219 initializeElement(node);
220 }
221 }
222
223 // Check child elements - select all elements with the attribute (any value or no value)
224 const children = node.querySelectorAll(`[${MACRO_AUTOCOMPLETE_ATTRIBUTE}]`);
225 for (const child of children) {
226 if (hasMacroAttribute(child) && (child instanceof HTMLTextAreaElement || child instanceof HTMLInputElement)) {
227 initializeElement(child);
228 }
229 }
230}
231
232/**
233 * MutationObserver to watch for dynamically added elements with macro autocomplete attribute.
234 * @type {MutationObserver}
235 */
236const observer = new MutationObserver(mutations => {
237 for (const mutation of mutations) {
238 if (mutation.type === 'childList') {
239 for (const node of mutation.addedNodes) {
240 handleNodeChange(node);
241 }
242 }
243 if (mutation.type === 'attributes') {
244 const target = mutation.target;
245 const isRelevantAttr = mutation.attributeName === MACRO_AUTOCOMPLETE_ATTRIBUTE ||
246 mutation.attributeName === MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE ||
247 mutation.attributeName === MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE;
248 if (isRelevantAttr && target instanceof Element) {
249 handleNodeChange(target);
250 }
251 }
252 }
253});
254
255/**
256 * Initializes macro autocomplete for all elements with the `data-macros` attribute.
257 * Also starts the MutationObserver to watch for dynamically added elements.
258 * Should be called after DOM is ready.
259 *
260 * @returns {AutoComplete[]} Array of autocomplete instances created.
261 */
262export function initMacroAutoComplete() {
263 const elements = /** @type {NodeListOf<HTMLTextAreaElement|HTMLInputElement>} */ (
264 document.querySelectorAll(`[${MACRO_AUTOCOMPLETE_ATTRIBUTE}]`)
265 );
266
267 const instances = [];
268 for (const element of elements) {
269 if (hasMacroAttribute(element)) {
270 const ac = initializeElement(element);
271 if (ac) {
272 instances.push(ac);
273 }
274 }
275 }
276
277 // Start observing for dynamically added elements
278 observer.observe(document.body, {
279 childList: true,
280 subtree: true,
281 attributes: true,
282 attributeFilter: [MACRO_AUTOCOMPLETE_ATTRIBUTE, MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE, MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE],
283 });
284
285 return instances;
286}
287
288/**
289 * Enables macro autocomplete on a specific element by ID.
290 * Adds the attribute and initializes autocomplete.
291 *
292 * @param {string} elementId - The element ID (without #).
293 * @returns {AutoComplete|null} The autocomplete instance, or null if element not found.
294 */
295export function enableMacroAutoCompleteById(elementId) {
296 const element = /** @type {HTMLTextAreaElement|HTMLInputElement|null} */ (
297 document.getElementById(elementId)
298 );
299
300 if (!element || !(element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement)) {
301 console.warn(`[MacroAutoComplete] Element not found or invalid: ${elementId}`);
302 return null;
303 }
304
305 element.setAttribute(MACRO_AUTOCOMPLETE_ATTRIBUTE, 'true');
306 return initializeElement(element);
307}