Macros 2.0 (v0.7.1) - Macro Autocomplete everywhere (#5019) * Add macro autocomplete for free text inputs Implements macro autocomplete (`{{`) for textareas and input fields marked with `data-macros` attribute. Extracts shared autocomplete logic into MacroAutoCompleteHelper.js for consistency between slash command and free text contexts. Includes MutationObserver for dynamic element initialization and supports variable shorthands, operators, flags, and scoped macro closing tags. * fix annoying circular dependency again * Add `data-macros` attribute to enable macro autocomplete on text inputs Enables macro autocomplete (`{{`) for various textareas and input fields throughout the UI by adding the `data-macros` attribute. Includes context template fields, instruct mode sequences, character/persona descriptions, world info content, and other prompt-related inputs. Also ensures the attribute is preserved when expanding editors and creating world info entries. * Allow macro autocomplete in editing messages Enables macro autocomplete (`{{`) when editing messages by adding the `data-macros` attribute to the dynamically created edit textarea element. * Add autocomplete visibility setting support for macro autocomplete Respects the global STScript autocomplete visibility setting (`power_user.stscript.autocomplete.state`) for macro autocomplete in free text inputs. When set to "Input length > 1", macro autocomplete only activates after typing `{{` plus at least 2 characters. Also adds a tooltip to the visibility setting explaining it applies to both slash commands and macros. * Fix unclosed div tag in STScript autocomplete visibility setting Closes the `<div>` tag that was incorrectly left open in the autocomplete visibility setting markup. * Add setting to control macro autocomplete visibility in non-expanded fields Introduces a new `showInAllMacroFields` setting that controls whether macro autocomplete appears in all macro-enabled fields or only in expanded editors and when pressing Ctrl+Space. Also adds `data-macros-autocomplete` attribute support with `always` and `hide` modes for per-field override. When the setting is off, autocomplete only shows in expanded editors (which now get `data-macros-autocomplete="always"`) and the completion * Add `data-macros-autocomplete-style` attribute to control macro autocomplete popup size Introduces a new `data-macros-autocomplete-style` attribute with `small` (33vw, max 700px) and `expanded` (default chat width) modes to control macro autocomplete popup dimensions. The `small` style is now the default for inline fields, while `expanded` is used for expanded editors and prompt manager. Also refactors `getAutocompleteMode` to return `DEFAULT` instead of `null` and adds corresponding `getAutocompleteStyle` helper * Fix autocomplete details panel alignment when list is constrained by viewport edge Adjusts the details panel position to align with the actual autocomplete list position when the list is constrained by the right edge of the viewport. Previously, the panel would position based on cursor location even when the list was pushed left, causing misalignment. Now checks if the list's actual position differs from the calculated position (with 5px tolerance) and uses the list's position instead. * Guard autocomplete details panel alignment check with `isReplaceable` condition Prevents attempting to read the autocomplete list's bounding rect when the list is not visible. The alignment adjustment for viewport-constrained lists now only runs when `isReplaceable` is true, avoiding potential errors when the list DOM is not rendered. * Fix macro autocomplete cursor detection to handle nested macros correctly Updates `findMacroAtCursor` to track nesting depth when searching for opening `{{` and closing `}}` braces. Previously would incorrectly stop at the first brace pair encountered in either direction, breaking autocomplete when cursor was inside nested macros like `{{getvar::{{getvar::name}}}}`. Now properly skips over nested macro boundaries by incrementing/decrementing depth counters until finding the matching braces at depth * gief me my comments back (and fixes, that were missed) - Added detailed JSDoc comments to all exported functions explaining parameters, return types, and behavior - Added inline comments throughout functions explaining logic flow, edge cases, and implementation details - Documented parser-based vs regex-based unclosed scope detection approaches - Clarified variable shorthand autocomplete logic including operator filtering, context display, and priority * refactor(macros): freeze MACRO_AUTOCOMPLETE_MODE and MACRO_AUTOCOMPLETE_STYLE enums * feat(macros): explicitly set `makeSelectable` flag for non-insertable autocomplete options - Set `makeSelectable = true` for variable shorthand options in {{if}} conditions (already insertable) - Set `makeSelectable = false` for already-typed operators in variable shorthand autocomplete - Set `makeSelectable = false` for already-typed flags in macro autocomplete - Set `makeSelectable = false` for non-selectable closing block flags when no unclosed scopes exist - Set `makeSelectable = false` for sc * refactor(macros): consolidate typedef imports in MacroAutoCompleteHelper - Fixed typo in MacroDefinition typedef import (removed extra `/` from JSDoc comment) - Replaced long-form typedef imports with short-form aliases for MacroAutoCompleteContext and MacroDefinition - Improved code consistency by using imported typedefs throughout function signatures * refactor(autocomplete): select first selectable item as default instead of always first item - Extracted default item selection logic into `selectDefaultItem()` method - Changed default selection to prioritize first selectable item over first item in list - Falls back to last item when no selectable items exist (preserves context for info-only options) - Added JSDoc documentation explaining selection behavior * feat(macros): make already-typed variable names non-selectable in autocomplete - Set `makeSelectable = false` for variable name options that match the currently typed name - Set empty `valueProvider` for matched variables to prevent re-insertion - Prevents redundant selection of variables that are already fully typed in the input * feat(macros): add value context autocomplete option for variable shorthand operators - Created `VariableValueContextAutoCompleteOption` class to display context about expected values - Shows operator name, symbol, and description when typing variable shorthand values - Displays currently typed value and completion hint - Non-selectable option (context only) with priority 4 - Added to variable shorthand autocomplete when operator is typed and value is being entered * fix lint --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -4006,7 +4006,7 @@ | ||
| 4006 | 4006 | <small data-i18n="Story String">Story String</small> |
| 4007 | 4007 | <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="context_story_string" title="Expand the editor" data-i18n="[title]Expand the editor"></i> |
| 4008 | 4008 | </label> |
| 4009 | 4009 | <textarea id="context_story_string" data-macros class="text_pole textarea_compact autoSetHeight"></textarea> |
| 4010 | 4010 | </div> |
| 4011 | 4011 | <div class="flex-container flexFlowColumn" data-cc-null> |
| 4012 | 4012 | <div id="context_story_string_position_block"> |
| @@ -4043,7 +4043,7 @@ | ||
| 4043 | 4043 | <small data-i18n="Example Separator">Example Separator</small> |
| 4044 | 4044 | </label> |
| 4045 | 4045 | <div> |
| 4046 | 4046 | <textarea id="context_example_separator" data-macros class="text_pole textarea_compact autoSetHeight"></textarea> |
| 4047 | 4047 | </div> |
| 4048 | 4048 | </div> |
| 4049 | 4049 | <div class="flex1"> |
| @@ -4051,7 +4051,7 @@ | ||
| 4051 | 4051 | <small data-i18n="Chat Start">Chat Start</small> |
| 4052 | 4052 | </label> |
| 4053 | 4053 | <div> |
| 4054 | 4054 | <textarea id="context_chat_start" data-macros class="text_pole textarea_compact autoSetHeight"></textarea> |
| 4055 | 4055 | </div> |
| 4056 | 4056 | </div> |
| 4057 | 4057 | </div> |
| @@ -4215,11 +4215,11 @@ | ||
| 4215 | 4215 | <div class="flex-container"> |
| 4216 | 4216 | <div class="flexAuto" title="Inserted before a User message and as a last prompt line when impersonating." data-i18n="[title]Inserted before a User message and as a last prompt line when impersonating."> |
| 4217 | 4217 | <small data-i18n="User Prefix">User Message Prefix</small> |
| 4218 | 4218 | <textarea id="instruct_input_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea> |
| 4219 | 4219 | </div> |
| 4220 | 4220 | <div class="flexAuto" title="Inserted after a User message." data-i18n="[title]Inserted after a User message."> |
| 4221 | 4221 | <small data-i18n="User Suffix">User Message Suffix</small> |
| 4222 | 4222 | <textarea id="instruct_input_suffix" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea> |
| 4223 | 4223 | </div> |
| 4224 | 4224 | </div> |
| 4225 | 4225 | </details> |
| @@ -4228,11 +4228,11 @@ | ||
| 4228 | 4228 | <div class="flex-container"> |
| 4229 | 4229 | <div class="flexAuto" title="Inserted before an Assistant message and as a last prompt line when generating an AI reply." data-i18n="[title]Inserted before an Assistant message and as a last prompt line when generating an AI reply."> |
| 4230 | 4230 | <small data-i18n="Assistant Prefix">Assistant Message Prefix</small> |
| 4231 | 4231 | <textarea id="instruct_output_sequence" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea> |
| 4232 | 4232 | </div> |
| 4233 | 4233 | <div class="flexAuto" title="Inserted after an Assistant message." data-i18n="[title]Inserted after an Assistant message."> |
| 4234 | 4234 | <small data-i18n="Assistant Suffix">Assistant Message Suffix</small> |
| 4235 | 4235 | <textarea id="instruct_output_suffix" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea> |
| 4236 | 4236 | </div> |
| 4237 | 4237 | </div> |
| 4238 | 4238 | </details> |
| @@ -4241,11 +4241,11 @@ | ||
| 4241 | 4241 | <div class="flex-container"> |
| 4242 | 4242 | <div class="flexAuto" id="instruct_system_sequence_block" title="Inserted before a System (added by slash commands or extensions) message." data-i18n="[title]Inserted before a System (added by slash commands or extensions) message."> |
| 4243 | 4243 | <small data-i18n="System Prefix">System Message Prefix</small> |
| 4244 | 4244 | <textarea id="instruct_system_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea> |
| 4245 | 4245 | </div> |
| 4246 | 4246 | <div class="flexAuto" id="instruct_system_suffix_block" title="Inserted after a System message." data-i18n="[title]Inserted after a System message."> |
| 4247 | 4247 | <small data-i18n="System Suffix">System Message Suffix</small> |
| 4248 | 4248 | <textarea id="instruct_system_suffix" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea> |
| 4249 | 4249 | </div> |
| 4250 | 4250 | </div> |
| 4251 | 4251 | <div class="flexBasis100p" title="If enabled, System Sequences will be the same as User Sequences." data-i18n="[title]If enabled, System Sequences will be the same as User Sequences."> |
| @@ -4260,37 +4260,37 @@ | ||
| 4260 | 4260 | <div class="flex-container"> |
| 4261 | 4261 | <div class="flexAuto" title="Inserted before the first Assistant's message." data-i18n="[title]Inserted before the first Assistant's message."> |
| 4262 | 4262 | <small data-i18n="First Assistant Prefix">First Assistant Prefix</small> |
| 4263 | 4263 | <textarea id="instruct_first_output_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea> |
| 4264 | 4264 | </div> |
| 4265 | 4265 | <div class="flexAuto" title="Inserted before the last Assistant's message or as a last prompt line when generating an AI reply (except a neutral/system role)." data-i18n="[title]instruct_last_output_sequence"> |
| 4266 | 4266 | <small data-i18n="Last Assistant Prefix">Last Assistant Prefix</small> |
| 4267 | 4267 | <textarea id="instruct_last_output_sequence" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea> |
| 4268 | 4268 | </div> |
| 4269 | 4269 | </div> |
| 4270 | 4270 | <div class="flex-container"> |
| 4271 | 4271 | <div class="flexAuto" title="Inserted before the first User's message." data-i18n="[title]Inserted before the first User's message."> |
| 4272 | 4272 | <small data-i18n="First User Prefix">First User Prefix</small> |
| 4273 | 4273 | <textarea id="instruct_first_input_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea> |
| 4274 | 4274 | </div> |
| 4275 | 4275 | <div class="flexAuto" title="Inserted before the last User's message." data-i18n="[title]instruct_last_input_sequence"> |
| 4276 | 4276 | <small data-i18n="Last User Prefix">Last User Prefix</small> |
| 4277 | 4277 | <textarea id="instruct_last_input_sequence" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea> |
| 4278 | 4278 | </div> |
| 4279 | 4279 | </div> |
| 4280 | 4280 | <div class="flex-container"> |
| 4281 | 4281 | <div class="flexAuto" title="Will be inserted as a last prompt line when using system/neutral generation." data-i18n="[title]Will be inserted as a last prompt line when using system/neutral generation."> |
| 4282 | 4282 | <small data-i18n="System Instruction Prefix">System Instruction Prefix</small> |
| 4283 | 4283 | <textarea id="instruct_last_system_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea> |
| 4284 | 4284 | </div> |
| 4285 | 4285 | <div class="flexAuto" title="If a stop sequence is generated, everything past it will be removed from the output (inclusive)." data-i18n="[title]If a stop sequence is generated, everything past it will be removed from the output (inclusive)."> |
| 4286 | 4286 | <small data-i18n="Stop Sequence">Stop Sequence</small> |
| 4287 | 4287 | <textarea id="instruct_stop_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea> |
| 4288 | 4288 | </div> |
| 4289 | 4289 | </div> |
| 4290 | 4290 | <div class="flex-container"> |
| 4291 | 4291 | <div class="flexAuto" title="Will be inserted at the start of the chat history if it doesn't start with a User message." data-i18n="[title]Will be inserted at the start of the chat history if it doesn't start with a User message."> |
| 4292 | 4292 | <small data-i18n="User Filler Message">User Filler Message</small> |
| 4293 | 4293 | <textarea id="instruct_user_alignment_message" data-macros class="text_pole textarea_compact autoSetHeight"></textarea> |
| 4294 | 4294 | </div> |
| 4295 | 4295 | </div> |
| 4296 | 4296 | </details> |
| @@ -4328,7 +4328,7 @@ | ||
| 4328 | 4328 | <small data-i18n="Prompt Content">Prompt Content</small> |
| 4329 | 4329 | <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="sysprompt_content" title="Expand the editor" data-i18n="[title]Expand the editor"></i> |
| 4330 | 4330 | </label> |
| 4331 | 4331 | <textarea id="sysprompt_content" data-macros class="text_pole textarea_compact autoSetHeight"></textarea> |
| 4332 | 4332 | </div> |
| 4333 | 4333 | |
| 4334 | 4334 | <div> |
| @@ -4336,7 +4336,7 @@ | ||
| 4336 | 4336 | <small data-i18n="Post-History Instructions">Post-History Instructions</small> |
| 4337 | 4337 | <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="sysprompt_post_history" title="Expand the editor" data-i18n="[title]Expand the editor"></i> |
| 4338 | 4338 | </label> |
| 4339 | 4339 | <textarea id="sysprompt_post_history" data-macros class="text_pole textarea_compact autoSetHeight"></textarea> |
| 4340 | 4340 | </div> |
| 4341 | 4341 | </div> |
| 4342 | 4342 | |
| @@ -4461,17 +4461,17 @@ | ||
| 4461 | 4461 | <div class="flex-container"> |
| 4462 | 4462 | <div class="flex1" title="Inserted before the reasoning content." data-i18n="[title]reasoning_prefix"> |
| 4463 | 4463 | <small data-i18n="Prefix">Prefix</small> |
| 4464 | 4464 | <textarea id="reasoning_prefix" data-macros class="text_pole textarea_compact autoSetHeight"></textarea> |
| 4465 | 4465 | </div> |
| 4466 | 4466 | <div class="flex1" title="Inserted after the reasoning content." data-i18n="[title]reasoning_suffix"> |
| 4467 | 4467 | <small data-i18n="Suffix">Suffix</small> |
| 4468 | 4468 | <textarea id="reasoning_suffix" data-macros class="text_pole textarea_compact autoSetHeight"></textarea> |
| 4469 | 4469 | </div> |
| 4470 | 4470 | </div> |
| 4471 | 4471 | <div class="flex-container"> |
| 4472 | 4472 | <div class="flex1" title="Inserted between the reasoning and the message content." data-i18n="[title]reasoning_separator"> |
| 4473 | 4473 | <small data-i18n="Separator">Separator</small> |
| 4474 | 4474 | <textarea id="reasoning_separator" data-macros class="text_pole textarea_compact autoSetHeight"></textarea> |
| 4475 | 4475 | </div> |
| 4476 | 4476 | </div> |
| 4477 | 4477 | </details> |
| @@ -4493,7 +4493,7 @@ | ||
| 4493 | 4493 | </span> |
| 4494 | 4494 | </small> |
| 4495 | 4495 | <div> |
| 4496 | 4496 | <input id="markdown_escape_strings" data-macros class="text_pole textarea_compact" type="text" data-i18n="[placeholder]comma delimited,no spaces between" placeholder="comma delimited,no spaces between" /> |
| 4497 | 4497 | </div> |
| 4498 | 4498 | </div> |
| 4499 | 4499 | |
| @@ -4505,7 +4505,7 @@ | ||
| 4505 | 4505 | </span> |
| 4506 | 4506 | </small> |
| 4507 | 4507 | <div> |
| 4508 | 4508 | <textarea id="start_reply_with" data-macros class="text_pole textarea_compact autoSetHeight"></textarea> |
| 4509 | 4509 | </div> |
| 4510 | 4510 | <label class="checkbox_label" for="chat-show-reply-prefix-checkbox"> |
| 4511 | 4511 | <input id="chat-show-reply-prefix-checkbox" type="checkbox" /> |
| @@ -5386,6 +5386,7 @@ | ||
| 5386 | 5386 | <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div> |
| 5387 | 5387 | </div> |
| 5388 | 5388 | <div class="inline-drawer-content"> |
| 5389 | + <div class="flex1" title="When to show the autocomplete for slash commands and macros." data-i18n="[title]When to show the autocomplete for slash commands and macros."> | |
| 5389 | 5390 | <label for="stscript_autocomplete_state"> |
| 5390 | 5391 | <small data-i18n="Visibility">Visibility</small> |
| 5391 | 5392 | </label> |
| @@ -5394,12 +5395,19 @@ | ||
| 5394 | 5395 | <option value="1" data-i18n="Input length > 1">Input length > 1</option> |
| 5395 | 5396 | <option value="2" data-i18n="Always show">Always show</option> |
| 5396 | 5397 | </select> |
| 5398 | + </div> | |
| 5397 | 5399 | <label class="checkbox_label" for="stscript_autocomplete_autoHide"> |
| 5398 | 5400 | <input id="stscript_autocomplete_autoHide" type="checkbox" /> |
| 5399 | 5401 | <small data-i18n="Automatically hide details"> |
| 5400 | 5402 | Automatically hide details |
| 5401 | 5403 | </small> |
| 5402 | 5404 | </label> |
| 5405 | + <label class="checkbox_label" for="stscript_autocomplete_showInAllMacroFields" title="Show macro autocomplete in all macro-enabled fields. When off, autocomplete only shows in expanded editors or when pressing Ctrl+Space." data-i18n="[title]Show macro autocomplete in all macro-enabled fields. When off, autocomplete only shows in expanded editors or when pressing Ctrl+Space."> | |
| 5406 | + <input id="stscript_autocomplete_showInAllMacroFields" type="checkbox" /> | |
| 5407 | + <small data-i18n="Show in all macro fields"> | |
| 5408 | + Show in all macro fields | |
| 5409 | + </small> | |
| 5410 | + </label> | |
| 5403 | 5411 | <div class="flex-container"> |
| 5404 | 5412 | <div class="flex1" title="Determines how entries are found for autocomplete." data-i18n="[title]Determines how entries are found for autocomplete."> |
| 5405 | 5413 | <label for="stscript_matching"> |
| @@ -5719,7 +5727,7 @@ | ||
| 5719 | 5727 | <span data-i18n="Persona Description">Persona Description</span> |
| 5720 | 5728 | <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="persona_description" title="Expand the editor" data-i18n="[title]Expand the editor"></i> |
| 5721 | 5729 | </h4> |
| 5722 | 5730 | <textarea id="persona_description" name="persona_description" data-macros data-i18n="[placeholder]Example: [{{user}} is a 28-year-old Romanian cat girl.]" placeholder="Example: [{{user}} is a 28-year-old Romanian cat girl.]" class="text_pole textarea_compact" value="" autocomplete="off" rows="8"></textarea> |
| 5723 | 5731 | |
| 5724 | 5732 | <div class="flex-container justifySpaceBetween"> |
| 5725 | 5733 | <h4 data-i18n="Position">Position</h4> |
| @@ -5978,7 +5986,7 @@ | ||
| 5978 | 5986 | </span> |
| 5979 | 5987 | </div> |
| 5980 | 5988 | </div> |
| 5981 | 5989 | <textarea id="description_textarea" class="mdHotkeys" data-macros data-i18n="[placeholder]Describe your character's physical and mental traits here." placeholder="Describe your character's physical and mental traits here." name="description" placeholder=""></textarea> |
| 5982 | 5990 | <div class="extension_token_counter"> |
| 5983 | 5991 | <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="description_textarea" data-token-permanent="true">counting...</span> |
| 5984 | 5992 | </div> |
| @@ -5998,7 +6006,7 @@ | ||
| 5998 | 6006 | </span> |
| 5999 | 6007 | </div> |
| 6000 | 6008 | </div> |
| 6001 | 6009 | <textarea classid="mdHotkeysfirstmessage_textarea" idclass="firstmessage_textareamdHotkeys" data-macros data-i18n="[placeholder]This will be the first message from the character that starts every chat." placeholder="This will be the first message from the character that starts every chat." name="first_mes" placeholder=""></textarea> |
| 6002 | 6010 | <div class="extension_token_counter"> |
| 6003 | 6011 | <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="firstmessage_textarea">counting...</span> |
| 6004 | 6012 | </div> |
| @@ -6322,7 +6330,7 @@ | ||
| 6322 | 6330 | <span data-i18n="Main Prompt">Main Prompt</span> |
| 6323 | 6331 | <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="system_prompt_textarea" title="Expand the editor" data-i18n="[title]Expand the editor"></i> |
| 6324 | 6332 | </h4> |
| 6325 | 6333 | <textarea id="system_prompt_textarea" name="system_prompt" data-macros data-i18n="[placeholder]Any contents here will replace the default Main Prompt used for this character. (v2 spec: system_prompt)" placeholder="Any contents here will replace the default Main Prompt used for this character. (v2 spec: system_prompt)" form="form_create" class="text_pole" autocomplete="off" rows="3"></textarea> |
| 6326 | 6334 | <div class="extension_token_counter"> |
| 6327 | 6335 | <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="system_prompt_textarea">counting...</span> |
| 6328 | 6336 | </div> |
| @@ -6332,7 +6340,7 @@ | ||
| 6332 | 6340 | <span data-i18n="Post-History Instructions">Post-History Instructions</span> |
| 6333 | 6341 | <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="post_history_instructions_textarea" title="Expand the editor" data-i18n="[title]Expand the editor"></i> |
| 6334 | 6342 | </h4> |
| 6335 | 6343 | <textarea id="post_history_instructions_textarea" name="post_history_instructions" data-macros data-i18n="[placeholder]Any contents here will replace the default Post-History Instructions used for this character. (v2 spec: post_history_instructions)" placeholder="Any contents here will replace the default Post-History Instructions used for this character. (v2 spec: post_history_instructions)" form="form_create" class="text_pole" autocomplete="off" rows="3"></textarea> |
| 6336 | 6344 | <div class="extension_token_counter"> |
| 6337 | 6345 | <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="post_history_instructions_textarea">counting...</span> |
| 6338 | 6346 | </div> |
| @@ -6385,7 +6393,7 @@ | ||
| 6385 | 6393 | <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="personality_textarea" title="Expand the editor" data-i18n="[title]Expand the editor"></i> |
| 6386 | 6394 | <a href="https://docs.sillytavern.app/usage/core-concepts/characterdesign/#personality-summary" class="notes-link" target="_blank"><span class="fa-solid fa-circle-question note-link-span"></span></a> |
| 6387 | 6395 | </h4> |
| 6388 | 6396 | <textarea id="personality_textarea" name="personality" data-macros data-i18n="[placeholder](A brief description of the personality)" placeholder="(A brief description of the personality)" form="form_create" class="text_pole" autocomplete="off" rows="4"></textarea> |
| 6389 | 6397 | <div class="extension_token_counter"> |
| 6390 | 6398 | <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="personality_textarea" data-token-permanent="true">counting...</span> |
| 6391 | 6399 | </div> |
| @@ -6398,7 +6406,7 @@ | ||
| 6398 | 6406 | <span class="fa-solid fa-circle-question note-link-span"></span> |
| 6399 | 6407 | </a> |
| 6400 | 6408 | </h4> |
| 6401 | 6409 | <textarea id="scenario_pole" name="scenario" data-macros data-i18n="[placeholder](Circumstances and context of the interaction)" placeholder="(Circumstances and context of the interaction)" class="text_pole" value="" autocomplete="off" form="form_create" rows="4"></textarea> |
| 6402 | 6410 | <div class="extension_token_counter"> |
| 6403 | 6411 | <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="scenario_pole" data-token-permanent="true">counting...</span> |
| 6404 | 6412 | </div> |
| @@ -6411,7 +6419,7 @@ | ||
| 6411 | 6419 | </span> |
| 6412 | 6420 | <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="depth_prompt_prompt" title="Expand the editor" data-i18n="[title]Expand the editor"></i> |
| 6413 | 6421 | </h4> |
| 6414 | 6422 | <textarea id="depth_prompt_prompt" name="depth_prompt_prompt" data-macros class="text_pole" rows="5" autocomplete="off" form="form_create" data-i18n="[placeholder](Text to be inserted in-chat @ designated depth and role)" placeholder="(Text to be inserted in-chat @ designated depth and role)"></textarea> |
| 6415 | 6423 | </div> |
| 6416 | 6424 | <div> |
| 6417 | 6425 | <h4> |
| @@ -6461,7 +6469,7 @@ | ||
| 6461 | 6469 | </a> |
| 6462 | 6470 | </h5> |
| 6463 | 6471 | </div> |
| 6464 | 6472 | <textarea id="mes_example_textarea" class="flexGrow mdHotkeys" name="mes_example" data-macros data-i18n="[placeholder](Examples of chat dialog. Begin each example with START on a new line.)" placeholder="(Examples of chat dialog. Begin each example with <START> on a new line.)" form="form_create" rows="6"></textarea> |
| 6465 | 6473 | <div class="extension_token_counter"> |
| 6466 | 6474 | <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="mes_example_textarea">counting...</span> |
| 6467 | 6475 | </div> |
| @@ -7154,7 +7162,7 @@ | ||
| 7154 | 7162 | <span> </span> |
| 7155 | 7163 | <span id="completion_prompt_manager_popup_entry_source"></span> |
| 7156 | 7164 | </div> |
| 7157 | 7165 | <textarea id="completion_prompt_manager_popup_entry_form_prompt" class="text_pole" name="prompt" data-macros data-macros-autocomplete="always" data-macros-autocomplete-style="expanded" placeholder="The prompt to be sent." data-i18n="[placeholder]The prompt to be sent."></textarea> |
| 7158 | 7166 | </div> |
| 7159 | 7167 | <div class="completion_prompt_manager_popup_entry_form_footer"> |
| 7160 | 7168 | <a id="completion_prompt_manager_popup_entry_form_close" title="Close" data-i18n="[title]close" class="fa-solid fa-close menu_button"></a> |
| @@ -7530,7 +7538,7 @@ | ||
| 7530 | 7538 | <b data-i18n="Unique to this chat">Unique to this chat</b>.<br> |
| 7531 | 7539 | <span data-i18n="Checkpoints inherit the Note from their parent, and can be changed individually after that.">Checkpoints inherit the Note from their parent, and can be changed individually after that.</span><br> |
| 7532 | 7540 | </small> |
| 7533 | 7541 | <textarea id="extension_floating_prompt" data-macros class="text_pole textarea_compact" rows="8"></textarea> |
| 7534 | 7542 | <div class="extension_token_counter"> |
| 7535 | 7543 | <span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_prompt_token_counter">0</span> |
| 7536 | 7544 | </div> |
| @@ -7587,7 +7595,7 @@ | ||
| 7587 | 7595 | <div class="inline-drawer-content"> |
| 7588 | 7596 | <small data-i18n="Will be automatically added as the author's note for this character. Will be used in groups, but can't be modified when a group chat is open.">Will be automatically added as the author's note for this character. Will be used in groups, but |
| 7589 | 7597 | can't be modified when a group chat is open.</small> |
| 7590 | 7598 | <textarea id="extension_floating_chara" data-macros class="text_pole textarea_compact" rows="8" placeholder="Example: [Scenario: wacky adventures; Genre: romantic comedy; Style: verbose, creative]"></textarea> |
| 7591 | 7599 | <div class="extension_token_counter"> |
| 7592 | 7600 | <span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_chara_token_counter">0</span> |
| 7593 | 7601 | </div> |
| @@ -7619,7 +7627,7 @@ | ||
| 7619 | 7627 | </div> |
| 7620 | 7628 | <div class="inline-drawer-content"> |
| 7621 | 7629 | <small data-i18n="Will be automatically added as the Author's Note for all new chats.">Will be automatically added as the Author's Note for all new chats.</small> |
| 7622 | 7630 | <textarea id="extension_floating_default" data-macros class="text_pole textarea_compact" rows="8" placeholder="Example: [Scenario: wacky adventures; Genre: romantic comedy; Style: verbose, creative]"></textarea> |
| 7623 | 7631 | <div class="extension_token_counter"> |
| 7624 | 7632 | <span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_default_token_counter">0</span> |
| 7625 | 7633 | </div> |
| @@ -189,6 +189,7 @@ import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids, | ||
| 189 | 189 | |
| 190 | 190 | import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors } from './scripts/extensions.js'; |
| 191 | 191 | import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, initSlashCommandAutoComplete, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js'; |
| 192 | +import { initMacroAutoComplete } from './scripts/autocomplete/MacroAutoComplete.js'; | |
| 192 | 193 | import { |
| 193 | 194 | tag_map, |
| 194 | 195 | tags, |
| @@ -711,6 +712,7 @@ async function firstLoadInit() { | ||
| 711 | 712 | initAuthorsNote(); |
| 712 | 713 | await initPersonas(); |
| 713 | 714 | await initSlashCommandAutoComplete(); |
| 715 | + initMacroAutoComplete(); | |
| 714 | 716 | initWorldInfo(); |
| 715 | 717 | initHorde(); |
| 716 | 718 | initRossMods(); |
| @@ -8028,6 +8030,7 @@ export async function messageEdit(editMessageId) { | ||
| 8028 | 8030 | const editTextArea = document.createElement('textarea'); |
| 8029 | 8031 | editTextArea.id = 'curEditTextarea'; |
| 8030 | 8032 | editTextArea.className = 'edit_textarea mdHotkeys'; |
| 8033 | + editTextArea.dataset.macros = ''; | |
| 8031 | 8034 | messageText.append(editTextArea); |
| 8032 | 8035 | |
| 8033 | 8036 | const text = trimSpaces(editMessage.mes || ''); |
| @@ -275,6 +275,7 @@ export class AutoComplete { | ||
| 275 | 275 | //TODO check if isInput and isForced are both required |
| 276 | 276 | this.text = this.textarea.value; |
| 277 | 277 | this.isReplaceable = false; |
| 278 | + this.isShowForced = isForced; // Store forced state for checkIfActivate to access | |
| 278 | 279 | |
| 279 | 280 | if (document.activeElement != this.textarea) { |
| 280 | 281 | // only show with textarea in focus |
| @@ -442,7 +443,7 @@ export class AutoComplete { | ||
| 442 | 443 | } else if (!this.isReplaceable && this.result.length > 1) { |
| 443 | 444 | return this.hide(); |
| 444 | 445 | } |
| 445 | 446 | this.selectedItem = this.selectDefaultItem(this.result[0]); |
| 446 | 447 | this.isActive = true; |
| 447 | 448 | this.wasForced = isForced; |
| 448 | 449 | this.renderDebounced(); |
| @@ -600,7 +601,22 @@ export class AutoComplete { | ||
| 600 | 601 | if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) { |
| 601 | 602 | return this.hide(); |
| 602 | 603 | } |
| 603 | 604 | constlet left = Math.max(rect.left, location.left) - layerRect.left; |
| 605 | + | |
| 606 | + // Check if the autocomplete list is constrained by the right edge of the viewport. | |
| 607 | + // If so, adjust the details panel position to align with the actual list position. | |
| 608 | + // Only do this when the list is actually visible (isReplaceable). | |
| 609 | + if (this.isReplaceable) { | |
| 610 | + const listRect = this.dom.getBoundingClientRect(); | |
| 611 | + const listActualLeft = listRect.left - layerRect.left; | |
| 612 | + const isConstrainedRight = listActualLeft < left - 5; // 5px tolerance | |
| 613 | + | |
| 614 | + if (isConstrainedRight) { | |
| 615 | + // Use the actual list position instead of cursor position | |
| 616 | + left = listActualLeft; | |
| 617 | + } | |
| 618 | + } | |
| 619 | + | |
| 604 | 620 | this.detailsWrap.style.setProperty('--targetOffset', `${left}`); |
| 605 | 621 | if (this.isReplaceable) { |
| 606 | 622 | this.detailsWrap.classList.remove('full'); |
| @@ -711,6 +727,24 @@ export class AutoComplete { | ||
| 711 | 727 | |
| 712 | 728 | |
| 713 | 729 | /** |
| 730 | + * Select the default item for the autocomplete list. | |
| 731 | + * Selects the first selectable item if any is present, or falls back to the last item. | |
| 732 | + * (To preserve context of where we are with multiple non-selectable options, if they are present for info) | |
| 733 | + * @param {AutoCompleteOption[]} result The list of autocomplete options. | |
| 734 | + * @returns {AutoCompleteOption} The item to select. | |
| 735 | + */ | |
| 736 | + selectDefaultItem(result) { | |
| 737 | + if (result.length === 0) return null; | |
| 738 | + | |
| 739 | + // Find first selectable item | |
| 740 | + const firstSelectable = result.find(it => it.isSelectable); | |
| 741 | + if (firstSelectable) return firstSelectable; | |
| 742 | + | |
| 743 | + // Fall back to last item | |
| 744 | + return result[result.length - 1]; | |
| 745 | + } | |
| 746 | + | |
| 747 | + /** | |
| 714 | 748 | * Mark the item at newIdx in the autocomplete list as selected. |
| 715 | 749 | * @param {number} newIdx |
| 716 | 750 | */ |
| @@ -1015,6 +1015,89 @@ export class VariableOperatorAutoCompleteOption extends AutoCompleteOption { | ||
| 1015 | 1015 | } |
| 1016 | 1016 | |
| 1017 | 1017 | /** |
| 1018 | + * Non-selectable autocomplete option that shows context about the value being typed. | |
| 1019 | + * Displays info about what value is expected based on the operator. | |
| 1020 | + */ | |
| 1021 | +export class VariableValueContextAutoCompleteOption extends AutoCompleteOption { | |
| 1022 | + /** @type {{ symbol: string, name: string, description: string, needsValue: boolean }} */ | |
| 1023 | + #operatorDef; | |
| 1024 | + | |
| 1025 | + /** @type {string} */ | |
| 1026 | + #currentValue; | |
| 1027 | + | |
| 1028 | + /** | |
| 1029 | + * @param {{ symbol: string, name: string, description: string, needsValue: boolean }} operatorDef - The operator definition. | |
| 1030 | + * @param {string} [currentValue=''] - The value currently being typed. | |
| 1031 | + */ | |
| 1032 | + constructor(operatorDef, currentValue = '') { | |
| 1033 | + super('value', '📝'); | |
| 1034 | + this.#operatorDef = operatorDef; | |
| 1035 | + this.#currentValue = currentValue; | |
| 1036 | + } | |
| 1037 | + | |
| 1038 | + /** @returns {{ symbol: string, name: string, description: string, needsValue: boolean }} */ | |
| 1039 | + get operatorDefinition() { | |
| 1040 | + return this.#operatorDef; | |
| 1041 | + } | |
| 1042 | + | |
| 1043 | + /** | |
| 1044 | + * Renders the autocomplete list item for this value context. | |
| 1045 | + * @returns {HTMLElement} | |
| 1046 | + */ | |
| 1047 | + renderItem() { | |
| 1048 | + const li = this.makeItem( | |
| 1049 | + '<value>', | |
| 1050 | + '📝', | |
| 1051 | + true, // noSlash | |
| 1052 | + [], // namedArguments | |
| 1053 | + [], // unnamedArguments | |
| 1054 | + 'any', // returnType | |
| 1055 | + `${this.#operatorDef.name} (${this.#operatorDef.symbol}) expects a value`, | |
| 1056 | + ); | |
| 1057 | + li.setAttribute('data-name', this.name); | |
| 1058 | + li.setAttribute('data-option-type', 'variable-value-context'); | |
| 1059 | + return li; | |
| 1060 | + } | |
| 1061 | + | |
| 1062 | + /** | |
| 1063 | + * Renders the details panel for this value context. | |
| 1064 | + * @returns {DocumentFragment} | |
| 1065 | + */ | |
| 1066 | + renderDetails() { | |
| 1067 | + const frag = document.createDocumentFragment(); | |
| 1068 | + | |
| 1069 | + const details = document.createElement('div'); | |
| 1070 | + details.classList.add('macro-variable-value-context-details'); | |
| 1071 | + | |
| 1072 | + // Header | |
| 1073 | + const header = document.createElement('h3'); | |
| 1074 | + header.innerHTML = `Value for <code>${this.#operatorDef.symbol}</code> (${this.#operatorDef.name})`; | |
| 1075 | + details.append(header); | |
| 1076 | + | |
| 1077 | + // Description of what value is expected | |
| 1078 | + const desc = document.createElement('p'); | |
| 1079 | + desc.textContent = this.#operatorDef.description; | |
| 1080 | + details.append(desc); | |
| 1081 | + | |
| 1082 | + // Current value being typed | |
| 1083 | + if (this.#currentValue) { | |
| 1084 | + const currentNote = document.createElement('p'); | |
| 1085 | + currentNote.innerHTML = `<em>Currently typing:</em> <code>${this.#currentValue}</code>`; | |
| 1086 | + details.append(currentNote); | |
| 1087 | + } | |
| 1088 | + | |
| 1089 | + // Hint | |
| 1090 | + const hint = document.createElement('p'); | |
| 1091 | + hint.classList.add('hint'); | |
| 1092 | + hint.innerHTML = '<em>Type your value and close with <code>}}</code> to complete the macro.</em>'; | |
| 1093 | + details.append(hint); | |
| 1094 | + | |
| 1095 | + frag.append(details); | |
| 1096 | + return frag; | |
| 1097 | + } | |
| 1098 | +} | |
| 1099 | + | |
| 1100 | +/** | |
| 1018 | 1101 | * Autocomplete option for closing a scoped macro. |
| 1019 | 1102 | * Suggests {{/macroName}} to close an unclosed scoped macro. |
| 1020 | 1103 | */ |
| @@ -0,0 +1,307 @@ | ||
| 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 | + | |
| 14 | +import { power_user } from '../power-user.js'; | |
| 15 | +import { AutoComplete, AUTOCOMPLETE_STATE } from './AutoComplete.js'; | |
| 16 | +import { findMacroAtCursor, findUnclosedScopes, getMacroAutoCompleteAt } from './MacroAutoCompleteHelper.js'; | |
| 17 | + | |
| 18 | +/** Custom attribute name used to mark elements that support macro autocomplete */ | |
| 19 | +export const MACRO_AUTOCOMPLETE_ATTRIBUTE = 'data-macros'; | |
| 20 | + | |
| 21 | +/** Attribute to control autocomplete visibility: 'always' (force show) or 'hide' (never show) */ | |
| 22 | +export const MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE = 'data-macros-autocomplete'; | |
| 23 | + | |
| 24 | +/** Generic attribute to control autocomplete popup style/size (used by AutoComplete) */ | |
| 25 | +export const MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE = 'data-macros-autocomplete-style'; | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * @readonly | |
| 29 | + * @enum {string} | |
| 30 | + */ | |
| 31 | +export 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 | + */ | |
| 44 | +export 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 */ | |
| 52 | +const initializedElements = new WeakSet(); | |
| 53 | + | |
| 54 | +/** @type {WeakMap<HTMLElement, AutoComplete>} Map elements to their autocomplete instances */ | |
| 55 | +const 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 | + */ | |
| 71 | +function 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 | + */ | |
| 119 | +export 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), | |
| 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 | + */ | |
| 141 | +function 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 | + */ | |
| 158 | +function 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 | + */ | |
| 175 | +function 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 | + */ | |
| 197 | +function 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 | + */ | |
| 211 | +function 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 | + */ | |
| 236 | +const 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 | + */ | |
| 262 | +export 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 | + */ | |
| 295 | +export 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 | +} | |
| @@ -0,0 +1,958 @@ | ||
| 1 | +/** | |
| 2 | + * Shared utilities for macro autocomplete functionality. | |
| 3 | + * Used by both SlashCommandParser (for slash command context) and MacroAutoComplete (for free text). | |
| 4 | + * | |
| 5 | + * This module extracts common macro autocomplete logic to avoid duplication and ensure | |
| 6 | + * consistent behavior across all contexts where macro autocomplete is used. | |
| 7 | + */ | |
| 8 | + | |
| 9 | +import { AutoCompleteNameResult } from './AutoCompleteNameResult.js'; | |
| 10 | +import { | |
| 11 | + EnhancedMacroAutoCompleteOption, | |
| 12 | + MacroFlagAutoCompleteOption, | |
| 13 | + MacroClosingTagAutoCompleteOption, | |
| 14 | + VariableShorthandAutoCompleteOption, | |
| 15 | + VariableShorthandDefinitions, | |
| 16 | + VariableNameAutoCompleteOption, | |
| 17 | + VariableOperatorAutoCompleteOption, | |
| 18 | + VariableValueContextAutoCompleteOption, | |
| 19 | + VariableOperatorDefinitions, | |
| 20 | + isValidVariableShorthandName, | |
| 21 | + parseMacroContext, | |
| 22 | + SimpleAutoCompleteOption, | |
| 23 | +} from './EnhancedMacroAutoCompleteOption.js'; | |
| 24 | +import { macros as macroSystem } from '../macros/macro-system.js'; | |
| 25 | +import { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js'; | |
| 26 | +import { MacroParser } from '../macros/engine/MacroParser.js'; | |
| 27 | +import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js'; | |
| 28 | +import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnostics.js'; | |
| 29 | +import { chat_metadata } from '/script.js'; | |
| 30 | +import { extension_settings } from '../extensions.js'; | |
| 31 | + | |
| 32 | +/** @typedef {import('./EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} MacroAutoCompleteContext */ | |
| 33 | +/** @typedef {import('./EnhancedMacroAutoCompleteOption.js').EnhancedMacroAutoCompleteOptions} EnhancedMacroAutoCompleteOptions */ | |
| 34 | +/** @typedef {import('./AutoCompleteOption.js').AutoCompleteOption} AutoCompleteOption */ | |
| 35 | +/*** @typedef {import('../macros/macro-system.js').MacroDefinition} MacroDefinition */ | |
| 36 | + | |
| 37 | +/** | |
| 38 | + * @typedef {Object} MacroInfo | |
| 39 | + * @property {number} start - Start position of the macro in text (at first {) | |
| 40 | + * @property {number} end - End position of the macro in text (after last }) | |
| 41 | + * @property {string} content - The content between {{ and }} | |
| 42 | + */ | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * @typedef {Object} UnclosedScope | |
| 46 | + * @property {string} name - Macro name | |
| 47 | + * @property {number} startOffset - Start position in text | |
| 48 | + * @property {number} endOffset - End position of opening tag | |
| 49 | + * @property {string} paddingBefore - Whitespace before macro name | |
| 50 | + * @property {string} paddingAfter - Whitespace after macro content | |
| 51 | + */ | |
| 52 | + | |
| 53 | +/** | |
| 54 | + * @typedef {Object} BuildMacroAutoCompleteOptions | |
| 55 | + * @property {MacroInfo|null} [macro=null] - Macro info if cursor is inside a macro | |
| 56 | + * @property {string|null} [textUpToCursor=null] - Pre-computed text up to cursor | |
| 57 | + * @property {UnclosedScope[]|null} [unclosedScopes=null] - Pre-computed unclosed scopes | |
| 58 | + */ | |
| 59 | + | |
| 60 | +/** | |
| 61 | + * Finds unclosed scoped macros in the text up to cursor position. | |
| 62 | + * Uses the MacroParser and MacroCstWalker for accurate analysis. | |
| 63 | + * | |
| 64 | + * @param {string} textUpToCursor - The document text up to the cursor position. | |
| 65 | + * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>} | |
| 66 | + */ | |
| 67 | +export function findUnclosedScopes(textUpToCursor) { | |
| 68 | + if (!textUpToCursor) return []; | |
| 69 | + | |
| 70 | + try { | |
| 71 | + // Parse the document to get the CST | |
| 72 | + const { cst } = MacroParser.parseDocument(textUpToCursor); | |
| 73 | + if (!cst) return []; | |
| 74 | + | |
| 75 | + // Use the CST walker to find unclosed scopes | |
| 76 | + return MacroCstWalker.findUnclosedScopes({ text: textUpToCursor, cst }); | |
| 77 | + } catch { | |
| 78 | + // If parsing fails (incomplete input), fall back to simple regex approach | |
| 79 | + return findUnclosedScopesRegex(textUpToCursor); | |
| 80 | + } | |
| 81 | +} | |
| 82 | + | |
| 83 | +/** | |
| 84 | + * Fallback regex-based approach for finding unclosed scopes. | |
| 85 | + * Used when the parser fails on incomplete input. | |
| 86 | + * | |
| 87 | + * @param {string} text - The text to analyze. | |
| 88 | + * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>} | |
| 89 | + */ | |
| 90 | +export function findUnclosedScopesRegex(text) { | |
| 91 | + // Regex to find macro openings and closings, capturing whitespace padding | |
| 92 | + // Group 1: padding after {{, Group 2: optional /, Group 3: macro name | |
| 93 | + const macroPattern = /\{\{(\s*)(\/?)([\w-]+)/g; | |
| 94 | + const stack = []; | |
| 95 | + | |
| 96 | + let match; | |
| 97 | + while ((match = macroPattern.exec(text)) !== null) { | |
| 98 | + const paddingBefore = match[1]; | |
| 99 | + const isClosing = match[2] === '/'; | |
| 100 | + const name = match[3]; | |
| 101 | + | |
| 102 | + if (isClosing) { | |
| 103 | + // Pop matching opener (case-insensitive) | |
| 104 | + if (stack.length > 0 && stack[stack.length - 1].name.toLowerCase() === name.toLowerCase()) { | |
| 105 | + stack.pop(); | |
| 106 | + } | |
| 107 | + } else { | |
| 108 | + // Check if macro can accept scoped content | |
| 109 | + const macroDef = macroSystem.registry.getPrimaryMacro(name); | |
| 110 | + if (macroDef && macroDef.maxArgs > 0) { | |
| 111 | + // Try to find closing }} to extract trailing whitespace | |
| 112 | + let paddingAfter = ''; | |
| 113 | + const afterMatch = text.slice(match.index + match[0].length); | |
| 114 | + const closingMatch = afterMatch.match(/^[^}]*?(\s*)\}\}/); | |
| 115 | + if (closingMatch) { | |
| 116 | + paddingAfter = closingMatch[1]; | |
| 117 | + } | |
| 118 | + | |
| 119 | + stack.push({ | |
| 120 | + name, | |
| 121 | + startOffset: match.index, | |
| 122 | + endOffset: match.index + match[0].length, | |
| 123 | + paddingBefore, | |
| 124 | + paddingAfter, | |
| 125 | + }); | |
| 126 | + } | |
| 127 | + } | |
| 128 | + } | |
| 129 | + | |
| 130 | + return stack; | |
| 131 | +} | |
| 132 | + | |
| 133 | +/** | |
| 134 | + * Builds autocomplete options for variable shorthand syntax (.varName or $varName). | |
| 135 | + * @param {MacroAutoCompleteContext} context | |
| 136 | + * @param {Object} [opts] - Optional configuration. | |
| 137 | + * @param {boolean} [opts.forIfCondition=false] - If true, options are for {{if}} condition (closes with }}). | |
| 138 | + * @param {string} [opts.paddingAfter=''] - Whitespace to add before closing }}. | |
| 139 | + * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} | |
| 140 | + */ | |
| 141 | +export function buildVariableShorthandOptions(context, opts = {}) { | |
| 142 | + const { forIfCondition = false, paddingAfter = '' } = opts; | |
| 143 | + /** @type {(VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption|VariableValueContextAutoCompleteOption)[]} */ | |
| 144 | + const options = []; | |
| 145 | + | |
| 146 | + const isLocal = context.variablePrefix === '.'; | |
| 147 | + const scope = isLocal ? 'local' : 'global'; | |
| 148 | + | |
| 149 | + | |
| 150 | + // Always show the typed variable prefix as a non-completable option (like flags do) | |
| 151 | + // This allows the details panel to show information about the prefix | |
| 152 | + const prefixDef = VariableShorthandDefinitions.get(context.variablePrefix); | |
| 153 | + if (prefixDef) { | |
| 154 | + const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef); | |
| 155 | + prefixOption.valueProvider = () => ''; // Already typed, don't re-insert | |
| 156 | + prefixOption.makeSelectable = false; | |
| 157 | + prefixOption.sortPriority = 1; // Show at top | |
| 158 | + prefixOption.matchProvider = () => true; // Always show regardless of filtering | |
| 159 | + options.push(prefixOption); | |
| 160 | + } | |
| 161 | + | |
| 162 | + // If typing the variable name, suggest existing variables | |
| 163 | + // Get existing variable names from the appropriate scope | |
| 164 | + // Filter to only include names that are valid for shorthand syntax | |
| 165 | + const existingVariables = getVariableNames(scope) | |
| 166 | + .filter(name => isValidVariableShorthandName(name)); | |
| 167 | + | |
| 168 | + // Check if the typed variable name exactly matches an existing variable | |
| 169 | + const variableNameMatchesExisting = context.variableName.length > 0 && existingVariables.includes(context.variableName); | |
| 170 | + | |
| 171 | + if (context.isTypingVariableName) { | |
| 172 | + // Add existing variables that match the typed name | |
| 173 | + for (const varName of existingVariables) { | |
| 174 | + const option = new VariableNameAutoCompleteOption(varName, scope, false); | |
| 175 | + // Not selectable if it matches the typed name | |
| 176 | + if (varName === context.variableName) { | |
| 177 | + option.valueProvider = () => ''; | |
| 178 | + option.makeSelectable = false; | |
| 179 | + } | |
| 180 | + // For {{if}} condition, provide full value with closing braces | |
| 181 | + if (forIfCondition) { | |
| 182 | + option.valueProvider = () => `${varName}${paddingAfter}}}`; // No variable prefix, as that has been written and committed already. | |
| 183 | + option.makeSelectable = true; | |
| 184 | + } | |
| 185 | + // Variables matching the typed prefix get higher priority | |
| 186 | + option.sortPriority = varName.startsWith(context.variableName) ? 3 : 10; | |
| 187 | + options.push(option); | |
| 188 | + } | |
| 189 | + | |
| 190 | + // If typing a name that doesn't exist, offer to create a new variable | |
| 191 | + // But if the name is invalid for shorthand syntax, show a warning instead | |
| 192 | + if (context.variableName.length > 0 && !existingVariables.includes(context.variableName)) { | |
| 193 | + const isInvalid = !isValidVariableShorthandName(context.variableName); | |
| 194 | + const newVarOption = new VariableNameAutoCompleteOption(context.variableName, scope, true, isInvalid); | |
| 195 | + newVarOption.sortPriority = isInvalid ? 2 : 4; // Invalid names get higher priority to show warning | |
| 196 | + if (isInvalid) { | |
| 197 | + // Make it non-selectable since it can't be used | |
| 198 | + newVarOption.valueProvider = () => ''; | |
| 199 | + newVarOption.makeSelectable = false; | |
| 200 | + } else if (forIfCondition) { | |
| 201 | + // For {{if}} condition, provide full value with closing braces | |
| 202 | + newVarOption.valueProvider = () => `${context.variablePrefix}${context.variableName}${paddingAfter}}}`; | |
| 203 | + newVarOption.makeSelectable = true; | |
| 204 | + } | |
| 205 | + options.push(newVarOption); | |
| 206 | + } | |
| 207 | + | |
| 208 | + // If the typed variable name exactly matches an existing variable, also show operators | |
| 209 | + // This allows users to see available operators without having to type a space first | |
| 210 | + if (variableNameMatchesExisting) { | |
| 211 | + for (const [, operatorDef] of VariableOperatorDefinitions) { | |
| 212 | + const opOption = new VariableOperatorAutoCompleteOption(operatorDef); | |
| 213 | + opOption.sortPriority = 6; // Lower priority than variable suggestions | |
| 214 | + opOption.matchProvider = () => true; // Always show | |
| 215 | + // IMPORTANT: Operators should INSERT after variable name, not replace it | |
| 216 | + // Use replacementStartOffset to shift insertion point past the variable name | |
| 217 | + opOption.replacementStartOffset = context.variableName.length; | |
| 218 | + options.push(opOption); | |
| 219 | + } | |
| 220 | + } | |
| 221 | + } | |
| 222 | + | |
| 223 | + // If there are invalid trailing characters after the variable name, show a warning | |
| 224 | + if (context.hasInvalidTrailingChars) { | |
| 225 | + // Show the full invalid name (variableName + invalidTrailingChars) with a warning | |
| 226 | + const fullInvalidName = context.variableName + (context.invalidTrailingChars || ''); | |
| 227 | + const invalidOption = new VariableNameAutoCompleteOption( | |
| 228 | + fullInvalidName, | |
| 229 | + scope, | |
| 230 | + false, | |
| 231 | + true, // isInvalidName - triggers warning display | |
| 232 | + ); | |
| 233 | + invalidOption.valueProvider = () => ''; // Don't insert anything | |
| 234 | + invalidOption.makeSelectable = false; | |
| 235 | + invalidOption.sortPriority = 2; | |
| 236 | + invalidOption.matchProvider = () => true; // Always show | |
| 237 | + options.push(invalidOption); | |
| 238 | + // Return early - don't show operators when syntax is invalid | |
| 239 | + return options; | |
| 240 | + } | |
| 241 | + | |
| 242 | + // If ready for operator (after variable name), suggest operators | |
| 243 | + if (context.isTypingOperator) { | |
| 244 | + // Show the current variable name as context (already typed) | |
| 245 | + const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false); | |
| 246 | + varNameOption.valueProvider = () => ''; // Already typed, don't re-insert | |
| 247 | + varNameOption.makeSelectable = false; | |
| 248 | + varNameOption.sortPriority = 2; | |
| 249 | + varNameOption.matchProvider = () => true; // Always show | |
| 250 | + options.push(varNameOption); | |
| 251 | + | |
| 252 | + // Then show available operators, filtered by partial prefix if any | |
| 253 | + // Also filter by current complete operator to show longer variants (e.g., > shows >=) | |
| 254 | + const partialOp = context.partialOperator || ''; | |
| 255 | + const currentOp = context.variableOperator || ''; | |
| 256 | + const filterPrefix = partialOp || currentOp; | |
| 257 | + for (const [, operatorDef] of VariableOperatorDefinitions) { | |
| 258 | + // Filter by operator prefix if user is typing one | |
| 259 | + // This allows typing ">" to show both ">" and ">=" | |
| 260 | + if (filterPrefix && !operatorDef.symbol.startsWith(filterPrefix)) { | |
| 261 | + continue; | |
| 262 | + } | |
| 263 | + const opOption = new VariableOperatorAutoCompleteOption(operatorDef); | |
| 264 | + // Exact match gets higher priority | |
| 265 | + opOption.sortPriority = operatorDef.symbol === currentOp ? 4 : 5; | |
| 266 | + // Already-typed operator is non-selectable | |
| 267 | + if (operatorDef.symbol === currentOp) { | |
| 268 | + opOption.valueProvider = () => ''; | |
| 269 | + opOption.makeSelectable = false; | |
| 270 | + } | |
| 271 | + // Always match operators when showing operator suggestions | |
| 272 | + opOption.matchProvider = () => true; | |
| 273 | + options.push(opOption); | |
| 274 | + } | |
| 275 | + } | |
| 276 | + | |
| 277 | + // If typing value (after = or +=), no autocomplete needed - freeform text | |
| 278 | + // But we show the current context for reference (greyed out, non-selectable) | |
| 279 | + if (context.isTypingValue && !context.isTypingOperator) { | |
| 280 | + // Show the current variable name as context (non-selectable) | |
| 281 | + const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false); | |
| 282 | + varNameOption.valueProvider = () => ''; // Context only | |
| 283 | + varNameOption.makeSelectable = false; | |
| 284 | + varNameOption.sortPriority = 2; | |
| 285 | + varNameOption.matchProvider = () => true; // Always show | |
| 286 | + options.push(varNameOption); | |
| 287 | + | |
| 288 | + // Show the operator that was used (non-selectable) | |
| 289 | + if (context.variableOperator) { | |
| 290 | + const opDef = VariableOperatorDefinitions.get(context.variableOperator); | |
| 291 | + if (opDef) { | |
| 292 | + const opOption = new VariableOperatorAutoCompleteOption(opDef); | |
| 293 | + opOption.valueProvider = () => ''; // Already typed | |
| 294 | + opOption.makeSelectable = false; | |
| 295 | + opOption.sortPriority = 3; | |
| 296 | + opOption.matchProvider = () => true; // Always show | |
| 297 | + options.push(opOption); | |
| 298 | + | |
| 299 | + // Show value context info (non-selectable) | |
| 300 | + const valueOption = new VariableValueContextAutoCompleteOption(opDef, context.variableValue); | |
| 301 | + valueOption.valueProvider = () => ''; // Context only | |
| 302 | + valueOption.makeSelectable = false; | |
| 303 | + valueOption.sortPriority = 4; | |
| 304 | + valueOption.matchProvider = () => true; // Always show | |
| 305 | + options.push(valueOption); | |
| 306 | + } | |
| 307 | + } | |
| 308 | + } | |
| 309 | + | |
| 310 | + // If operator is complete (++ or --), show context without value input (non-selectable) | |
| 311 | + if (context.isOperatorComplete && !context.isTypingOperator) { | |
| 312 | + // Show the current variable name as context (non-selectable) | |
| 313 | + const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false); | |
| 314 | + varNameOption.valueProvider = () => ''; // Context only | |
| 315 | + varNameOption.makeSelectable = false; | |
| 316 | + varNameOption.sortPriority = 2; | |
| 317 | + varNameOption.matchProvider = () => true; // Always show | |
| 318 | + options.push(varNameOption); | |
| 319 | + | |
| 320 | + // Show the operator that was used (non-selectable) | |
| 321 | + if (context.variableOperator) { | |
| 322 | + const opDef = VariableOperatorDefinitions.get(context.variableOperator); | |
| 323 | + if (opDef) { | |
| 324 | + const opOption = new VariableOperatorAutoCompleteOption(opDef); | |
| 325 | + opOption.valueProvider = () => ''; // Already typed | |
| 326 | + opOption.makeSelectable = false; | |
| 327 | + opOption.sortPriority = 3; | |
| 328 | + opOption.matchProvider = () => true; // Always show | |
| 329 | + options.push(opOption); | |
| 330 | + } | |
| 331 | + } | |
| 332 | + } | |
| 333 | + | |
| 334 | + return options; | |
| 335 | +} | |
| 336 | + | |
| 337 | +/** | |
| 338 | + * Builds enhanced macro autocomplete options from the MacroRegistry. | |
| 339 | + * When in the flags area (before identifier), includes flag options. | |
| 340 | + * When typing arguments (after ::), prioritizes the exact macro match. | |
| 341 | + * @param {MacroAutoCompleteContext} context | |
| 342 | + * @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection. | |
| 343 | + * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} | |
| 344 | + */ | |
| 345 | +export function buildEnhancedMacroOptions(context, textUpToCursor) { | |
| 346 | + /** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} */ | |
| 347 | + const options = []; | |
| 348 | + | |
| 349 | + if (context.isVariableShorthand) { | |
| 350 | + return buildVariableShorthandOptions(context); | |
| 351 | + } | |
| 352 | + | |
| 353 | + // Check for unclosed scoped macros and suggest closing tags first | |
| 354 | + const unclosedScopes = findUnclosedScopes(textUpToCursor); | |
| 355 | + if (unclosedScopes.length > 0) { | |
| 356 | + // Suggest closing the innermost (last) unclosed scope first | |
| 357 | + const innermostScope = unclosedScopes[unclosedScopes.length - 1]; | |
| 358 | + // Preserve whitespace padding from the opening tag | |
| 359 | + // Pass currentPadding so the closing tag can replace user-typed whitespace with the target padding | |
| 360 | + const closingOption = new MacroClosingTagAutoCompleteOption(innermostScope.name, { | |
| 361 | + paddingBefore: innermostScope.paddingBefore, | |
| 362 | + paddingAfter: innermostScope.paddingAfter, | |
| 363 | + currentPadding: context.paddingBefore, | |
| 364 | + }); | |
| 365 | + options.push(closingOption); | |
| 366 | + | |
| 367 | + // If inside a scoped {{if}}, also suggest {{else}} | |
| 368 | + if (innermostScope.name === 'if') { | |
| 369 | + const macroDef = macroSystem.registry.getPrimaryMacro('else'); | |
| 370 | + const elseOption = new EnhancedMacroAutoCompleteOption(macroDef); | |
| 371 | + elseOption.sortPriority = 2; | |
| 372 | + options.push(elseOption); | |
| 373 | + } | |
| 374 | + } | |
| 375 | + | |
| 376 | + // If cursor is in the flags area (before identifier starts), include flag options | |
| 377 | + if (context.isInFlagsArea) { | |
| 378 | + // Build flag options with priority-based sorting | |
| 379 | + // Last typed flag has highest priority (1), other flags have lower priority (10) | |
| 380 | + // Already-typed flags (except last) are hidden from the list | |
| 381 | + const lastTypedFlag = context.flags.length > 0 ? context.flags[context.flags.length - 1] : null; | |
| 382 | + | |
| 383 | + // Add last typed flag with high priority (so it appears at top) | |
| 384 | + if (lastTypedFlag) { | |
| 385 | + const lastFlagDef = MacroFlagDefinitions.get(lastTypedFlag); | |
| 386 | + if (lastFlagDef) { | |
| 387 | + const lastFlagOption = new MacroFlagAutoCompleteOption(lastFlagDef); | |
| 388 | + // Mark as already typed - valueProvider returns empty so it doesn't re-insert | |
| 389 | + lastFlagOption.valueProvider = () => ''; | |
| 390 | + lastFlagOption.makeSelectable = false; | |
| 391 | + // High priority to appear at top (after closing tags at 1) | |
| 392 | + lastFlagOption.sortPriority = 2; | |
| 393 | + options.push(lastFlagOption); | |
| 394 | + } | |
| 395 | + } | |
| 396 | + | |
| 397 | + // Add flags that haven't been typed yet (skip already-typed ones except last) | |
| 398 | + for (const [symbol, flagDef] of MacroFlagDefinitions) { | |
| 399 | + // Skip the last typed flag (already added above) and other already-typed flags | |
| 400 | + if (context.flags.includes(symbol)) { | |
| 401 | + continue; | |
| 402 | + } | |
| 403 | + const flagOption = new MacroFlagAutoCompleteOption(flagDef); | |
| 404 | + | |
| 405 | + // Define whether this flag is selectable (and at the top), based on being implemented, and closing actually being relevant | |
| 406 | + let isSelectable = flagDef.implemented; | |
| 407 | + if (flagDef.type === MacroFlagType.CLOSING_BLOCK && !unclosedScopes.length) isSelectable = false; | |
| 408 | + if (!isSelectable) { | |
| 409 | + flagOption.valueProvider = () => ''; | |
| 410 | + flagOption.makeSelectable = false; | |
| 411 | + } | |
| 412 | + // Normal flag priority | |
| 413 | + flagOption.sortPriority = isSelectable ? 10 : 12; | |
| 414 | + options.push(flagOption); | |
| 415 | + } | |
| 416 | + | |
| 417 | + // Add variable shorthand prefix options (. for local, $ for global) | |
| 418 | + // These allow users to type variable shorthands instead of macro names | |
| 419 | + for (const [, varShorthandDef] of VariableShorthandDefinitions) { | |
| 420 | + const varOption = new VariableShorthandAutoCompleteOption(varShorthandDef); | |
| 421 | + varOption.sortPriority = 8; // Between implemented flags (10) and unimplemented (12) | |
| 422 | + options.push(varOption); | |
| 423 | + } | |
| 424 | + } | |
| 425 | + | |
| 426 | + // Get all macros from the registry (excluding hidden aliases) | |
| 427 | + const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true }); | |
| 428 | + | |
| 429 | + // If we're typing arguments (after ::), only show the context to the matching macro | |
| 430 | + const isTypingArgs = context.currentArgIndex >= 0; | |
| 431 | + | |
| 432 | + // Check if we're inside a scoped {{if}} for {{else}} selectability | |
| 433 | + const isInsideScopedIf = unclosedScopes.some(scope => scope.name === 'if'); | |
| 434 | + | |
| 435 | + for (const macro of allMacros) { | |
| 436 | + // Check if this macro matches the typed identifier | |
| 437 | + const isExactMatch = macro.name === context.identifier; | |
| 438 | + const isAliasMatch = macro.aliasOf === context.identifier; | |
| 439 | + | |
| 440 | + // Only pass context to the macro that matches the identifier being typed | |
| 441 | + // This ensures argument hints only show for the relevant macro | |
| 442 | + /** @type {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} */ | |
| 443 | + let macroContext = (isExactMatch || isAliasMatch) ? context : null; | |
| 444 | + | |
| 445 | + // If no context, we pass some options for additional details though | |
| 446 | + if (!macroContext) { | |
| 447 | + macroContext = /** @type {EnhancedMacroAutoCompleteOptions} */ ({ | |
| 448 | + paddingAfter: context.paddingBefore, // Match whitespace before the macro - will only be used if the macro gets auto-closed | |
| 449 | + flags: context.flags, | |
| 450 | + currentFlag: context.currentFlag, | |
| 451 | + fullText: context.fullText, | |
| 452 | + }); | |
| 453 | + } | |
| 454 | + | |
| 455 | + const option = new EnhancedMacroAutoCompleteOption(macro, macroContext); | |
| 456 | + | |
| 457 | + // {{else}} is only selectable inside a scoped {{if}} block | |
| 458 | + // Outside of {{if}}, it should appear in the list but not be tab-completable | |
| 459 | + if (macro.name === 'else' && !isInsideScopedIf) { | |
| 460 | + option.valueProvider = () => ''; | |
| 461 | + option.makeSelectable = false; | |
| 462 | + } | |
| 463 | + | |
| 464 | + // When typing arguments, prioritize exact matches by putting them first | |
| 465 | + if (isTypingArgs && (isExactMatch || isAliasMatch)) { | |
| 466 | + options.unshift(option); | |
| 467 | + } else { | |
| 468 | + options.push(option); | |
| 469 | + } | |
| 470 | + } | |
| 471 | + | |
| 472 | + return options; | |
| 473 | +} | |
| 474 | + | |
| 475 | +/** | |
| 476 | + * Builds autocomplete options for {{if}} condition - shows zero-arg macros as shorthand. | |
| 477 | + * @param {MacroAutoCompleteContext} context | |
| 478 | + * @param {MacroDefinition[]} allMacros | |
| 479 | + * @param {string} macroInnerText - The text inside the macro braces (e.g., " if pers" from "{{ if pers"). | |
| 480 | + * @returns {AutoCompleteOption[]} | |
| 481 | + */ | |
| 482 | +export function buildIfConditionOptions(context, allMacros, macroInnerText) { | |
| 483 | + /** @type {AutoCompleteOption[]} */ | |
| 484 | + const options = []; | |
| 485 | + | |
| 486 | + // Calculate padding from the original macro text for matching whitespace on completion | |
| 487 | + // e.g., " if pers" -> leading padding = " " (whitespace before 'if', used before '}}') | |
| 488 | + const leadingMatch = macroInnerText.match(/^(\s*)/); | |
| 489 | + const paddingAfter = leadingMatch ? leadingMatch[1] : ''; | |
| 490 | + | |
| 491 | + // Get the condition text being typed (trimmed for detection) | |
| 492 | + const conditionText = (context.args[0] || '').trim(); | |
| 493 | + | |
| 494 | + // Check for inversion prefix (!) - also trim whitespace after ! | |
| 495 | + const hasInversionPrefix = conditionText.startsWith('!'); | |
| 496 | + const conditionAfterInversion = hasInversionPrefix ? conditionText.slice(1).trimStart() : conditionText; | |
| 497 | + | |
| 498 | + const inversionOption = new SimpleAutoCompleteOption({ | |
| 499 | + name: '!', | |
| 500 | + symbol: '🔁', | |
| 501 | + description: 'Invert condition (NOT)', | |
| 502 | + detailedDescription: 'Inverts the condition result. If the condition is truthy, it becomes falsy, and vice versa.<br><br>Example: <code>{{if !myVar}}</code> executes when <code>myVar</code> is empty or zero.', | |
| 503 | + type: 'inverse', | |
| 504 | + }); | |
| 505 | + | |
| 506 | + // Check if condition starts with a variable shorthand prefix (with or without !) | |
| 507 | + const isTypingVariableShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$'); | |
| 508 | + | |
| 509 | + if (isTypingVariableShorthand) { | |
| 510 | + // User is typing a variable shorthand - reuse #buildVariableShorthandOptions | |
| 511 | + const prefix = /** @type {'.'|'$'} */ (conditionAfterInversion[0]); | |
| 512 | + const varNameTyped = conditionAfterInversion.slice(1); // Variable name after the prefix | |
| 513 | + | |
| 514 | + // If inverted, show the ! as non-selectable context | |
| 515 | + if (hasInversionPrefix) { | |
| 516 | + inversionOption.valueProvider = () => ''; // Already typed | |
| 517 | + inversionOption.makeSelectable = false; | |
| 518 | + inversionOption.sortPriority = 0; | |
| 519 | + options.push(inversionOption); | |
| 520 | + } | |
| 521 | + | |
| 522 | + // Create a synthetic context for #buildVariableShorthandOptions | |
| 523 | + /** @type {MacroAutoCompleteContext} */ | |
| 524 | + const varContext = { | |
| 525 | + ...context, | |
| 526 | + isVariableShorthand: true, | |
| 527 | + variablePrefix: prefix, | |
| 528 | + variableName: varNameTyped, | |
| 529 | + isTypingVariableName: true, | |
| 530 | + isTypingOperator: false, | |
| 531 | + isTypingValue: false, | |
| 532 | + isOperatorComplete: false, | |
| 533 | + hasInvalidTrailingChars: false, | |
| 534 | + variableOperator: null, | |
| 535 | + variableValue: '', | |
| 536 | + }; | |
| 537 | + | |
| 538 | + const varOptions = buildVariableShorthandOptions(varContext, { forIfCondition: true, paddingAfter }); | |
| 539 | + options.push(...varOptions); | |
| 540 | + return options; | |
| 541 | + } | |
| 542 | + | |
| 543 | + // Not typing a variable shorthand - show macro options, variable shorthand prefixes, and inversion | |
| 544 | + | |
| 545 | + // Show ! inversion option at the top when nothing typed, or keep it visible (non-selectable) if already typed | |
| 546 | + if (conditionText.length === 0) { | |
| 547 | + // Nothing typed - offer ! as selectable option | |
| 548 | + inversionOption.valueProvider = () => '!'; | |
| 549 | + inversionOption.makeSelectable = true; | |
| 550 | + inversionOption.sortPriority = -1; // Show at very top | |
| 551 | + options.push(inversionOption); | |
| 552 | + } else if (hasInversionPrefix && conditionAfterInversion.length === 0) { | |
| 553 | + // Just ! typed - show it as non-selectable context, then show macro names and variable prefixes | |
| 554 | + inversionOption.valueProvider = () => ''; // Already typed | |
| 555 | + inversionOption.makeSelectable = false; | |
| 556 | + inversionOption.sortPriority = -1; | |
| 557 | + options.push(inversionOption); | |
| 558 | + } | |
| 559 | + | |
| 560 | + // Add variable shorthand prefix options when no content typed yet (or just ! typed) | |
| 561 | + if (conditionAfterInversion.length === 0) { | |
| 562 | + for (const [, prefixDef] of VariableShorthandDefinitions) { | |
| 563 | + const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef); | |
| 564 | + // Complete with just the prefix symbol | |
| 565 | + prefixOption.valueProvider = () => prefixDef.type; | |
| 566 | + prefixOption.makeSelectable = true; | |
| 567 | + prefixOption.sortPriority = 0; // Show at top | |
| 568 | + options.push(prefixOption); | |
| 569 | + } | |
| 570 | + } | |
| 571 | + | |
| 572 | + // Add zero-arg macros as condition shorthand options | |
| 573 | + for (const macro of allMacros) { | |
| 574 | + // Only include macros that require zero arguments (can be auto-resolved) | |
| 575 | + if (macro.minArgs !== 0) continue; | |
| 576 | + | |
| 577 | + // Skip internal/utility macros that don't make sense as conditions | |
| 578 | + if (['else', 'noop', 'trim', '//'].includes(macro.name)) continue; | |
| 579 | + | |
| 580 | + const option = new EnhancedMacroAutoCompleteOption(macro, { | |
| 581 | + noBraces: true, | |
| 582 | + paddingAfter, | |
| 583 | + closeWithBraces: true, | |
| 584 | + }); | |
| 585 | + options.push(option); | |
| 586 | + } | |
| 587 | + | |
| 588 | + return options; | |
| 589 | +} | |
| 590 | + | |
| 591 | +/** | |
| 592 | + * Finds macro boundaries at a given cursor position in any text. | |
| 593 | + * Works independently of slash command parsing. | |
| 594 | + * | |
| 595 | + * @param {string} text - The full text content. | |
| 596 | + * @param {number} cursorPos - The cursor position in the text. | |
| 597 | + * @returns {{ start: number, end: number, content: string } | null} | |
| 598 | + */ | |
| 599 | +export function findMacroAtCursor(text, cursorPos) { | |
| 600 | + // Search backwards for opening {{ while tracking nesting depth for nested macros | |
| 601 | + let openPos = -1; | |
| 602 | + let depth = 0; | |
| 603 | + for (let i = cursorPos - 1; i >= 0; i--) { | |
| 604 | + if (text[i] === '}' && i > 0 && text[i - 1] === '}') { | |
| 605 | + // Found }}, going backwards means we're entering a nested macro | |
| 606 | + depth++; | |
| 607 | + i--; // Skip the other brace | |
| 608 | + continue; | |
| 609 | + } | |
| 610 | + if (text[i] === '{' && i > 0 && text[i - 1] === '{') { | |
| 611 | + if (depth > 0) { | |
| 612 | + // This {{ closes a nested macro we entered going backwards | |
| 613 | + depth--; | |
| 614 | + i--; // Skip the other brace | |
| 615 | + continue; | |
| 616 | + } | |
| 617 | + // Found our opening {{ at depth 0 | |
| 618 | + openPos = i - 1; | |
| 619 | + break; | |
| 620 | + } | |
| 621 | + } | |
| 622 | + | |
| 623 | + if (openPos === -1) return null; | |
| 624 | + | |
| 625 | + // Search forwards for closing }} while tracking nesting depth | |
| 626 | + let closePos = -1; | |
| 627 | + depth = 0; | |
| 628 | + for (let i = cursorPos; i < text.length - 1; i++) { | |
| 629 | + if (text[i] === '{' && text[i + 1] === '{') { | |
| 630 | + // Found {{, entering a nested macro | |
| 631 | + depth++; | |
| 632 | + i++; // Skip the other brace | |
| 633 | + continue; | |
| 634 | + } | |
| 635 | + if (text[i] === '}' && text[i + 1] === '}') { | |
| 636 | + if (depth > 0) { | |
| 637 | + // This }} closes a nested macro | |
| 638 | + depth--; | |
| 639 | + i++; // Skip the other brace | |
| 640 | + continue; | |
| 641 | + } | |
| 642 | + // Found our closing }} at depth 0 | |
| 643 | + closePos = i + 2; | |
| 644 | + break; | |
| 645 | + } | |
| 646 | + } | |
| 647 | + | |
| 648 | + if (closePos === -1) { | |
| 649 | + closePos = text.length; | |
| 650 | + } | |
| 651 | + | |
| 652 | + const hasClosingBraces = closePos <= text.length && text.slice(closePos - 2, closePos) === '}}'; | |
| 653 | + const content = text.slice(openPos + 2, hasClosingBraces ? closePos - 2 : closePos); | |
| 654 | + | |
| 655 | + return { | |
| 656 | + start: openPos, | |
| 657 | + end: closePos, | |
| 658 | + content, | |
| 659 | + }; | |
| 660 | +} | |
| 661 | + | |
| 662 | +/** | |
| 663 | + * Gets variable names from the specified scope. | |
| 664 | + * | |
| 665 | + * @param {'local'|'global'} scope - The variable scope. | |
| 666 | + * @returns {string[]} Array of variable names. | |
| 667 | + */ | |
| 668 | +export function getVariableNames(scope) { | |
| 669 | + try { | |
| 670 | + // Import chat_metadata and extension_settings dynamically to avoid circular deps | |
| 671 | + // These are the same sources used by commonEnumProviders.variables | |
| 672 | + if (scope === 'local') { | |
| 673 | + // Local variables are in chat_metadata.variables | |
| 674 | + return Object.keys(chat_metadata?.variables ?? {}); | |
| 675 | + } else { | |
| 676 | + // Global variables are in extension_settings.variables.global | |
| 677 | + return Object.keys(extension_settings?.variables?.global ?? {}); | |
| 678 | + } | |
| 679 | + } catch { | |
| 680 | + return []; | |
| 681 | + } | |
| 682 | +} | |
| 683 | + | |
| 684 | +/** | |
| 685 | + * Core function to build macro autocomplete results. | |
| 686 | + * Used by both SlashCommandParser (slash command context) and MacroAutoComplete (free text). | |
| 687 | + * | |
| 688 | + * This is the shared implementation that handles: | |
| 689 | + * - Scoped content detection and context display | |
| 690 | + * - {{if}} condition special handling | |
| 691 | + * - Variable shorthand syntax (.var, $var) | |
| 692 | + * - Flag handling | |
| 693 | + * - Regular macro options | |
| 694 | + * | |
| 695 | + * @param {string} text - The full text content. | |
| 696 | + * @param {number} cursorPos - The cursor position. | |
| 697 | + * @param {BuildMacroAutoCompleteOptions} [options={}] - Optional pre-computed values. | |
| 698 | + * @returns {Promise<AutoCompleteNameResult|null>} | |
| 699 | + */ | |
| 700 | +export async function buildMacroAutoCompleteResult(text, cursorPos, { | |
| 701 | + macro = null, | |
| 702 | + textUpToCursor = null, | |
| 703 | + unclosedScopes = null, | |
| 704 | +} = {}) { | |
| 705 | + // Compute textUpToCursor if not provided | |
| 706 | + if (textUpToCursor === null) { | |
| 707 | + textUpToCursor = text.slice(0, cursorPos); | |
| 708 | + } | |
| 709 | + | |
| 710 | + // Compute unclosedScopes if not provided | |
| 711 | + if (unclosedScopes === null) { | |
| 712 | + unclosedScopes = findUnclosedScopes(textUpToCursor); | |
| 713 | + } | |
| 714 | + | |
| 715 | + // If cursor is NOT inside a macro, check if we're in scoped content | |
| 716 | + if (!macro) { | |
| 717 | + if (unclosedScopes.length > 0) { | |
| 718 | + const scopedMacro = unclosedScopes[unclosedScopes.length - 1]; | |
| 719 | + | |
| 720 | + // Find where the opening macro ends | |
| 721 | + const openingEnd = text.indexOf('}}', scopedMacro.startOffset); | |
| 722 | + if (openingEnd !== -1 && cursorPos >= openingEnd + 2) { | |
| 723 | + // We're in scoped content - show parent macro's details | |
| 724 | + const macroContent = text.slice(scopedMacro.startOffset + 2, openingEnd); | |
| 725 | + const baseContext = parseMacroContext(macroContent, macroContent.length); | |
| 726 | + | |
| 727 | + const scopedContext = { | |
| 728 | + ...baseContext, | |
| 729 | + currentArgIndex: baseContext.args.length, | |
| 730 | + isInScopedContent: true, | |
| 731 | + scopedMacroName: scopedMacro.name, | |
| 732 | + }; | |
| 733 | + | |
| 734 | + await onboardingExperimentalMacroEngine('scoped macros'); | |
| 735 | + | |
| 736 | + const macroDef = macroSystem.registry.getPrimaryMacro(scopedMacro.name); | |
| 737 | + if (macroDef) { | |
| 738 | + const scopedOption = new EnhancedMacroAutoCompleteOption(macroDef, scopedContext); | |
| 739 | + scopedOption.valueProvider = () => ''; | |
| 740 | + scopedOption.makeSelectable = false; | |
| 741 | + | |
| 742 | + return new AutoCompleteNameResult( | |
| 743 | + scopedMacro.name, | |
| 744 | + scopedMacro.startOffset + 2, | |
| 745 | + [scopedOption], | |
| 746 | + false, | |
| 747 | + ); | |
| 748 | + } | |
| 749 | + } | |
| 750 | + } | |
| 751 | + return null; | |
| 752 | + } | |
| 753 | + | |
| 754 | + // Cursor is inside a macro - parse context | |
| 755 | + const cursorInMacro = cursorPos - macro.start - 2; | |
| 756 | + const context = parseMacroContext(macro.content, cursorInMacro); | |
| 757 | + | |
| 758 | + // Check if cursor is at/after closing }} | |
| 759 | + const macroEndsBrackets = text.slice(macro.end - 2, macro.end) === '}}'; | |
| 760 | + const isCursorAtClosing = macroEndsBrackets && cursorPos >= macro.end - 1; | |
| 761 | + | |
| 762 | + if (isCursorAtClosing) { | |
| 763 | + // Cursor is at the closing }} - check if this is an unclosed scoped macro | |
| 764 | + if (unclosedScopes.length > 0) { | |
| 765 | + const scopedMacro = unclosedScopes[unclosedScopes.length - 1]; | |
| 766 | + // Check if the current macro IS the unclosed scoped macro | |
| 767 | + if (scopedMacro.startOffset === macro.start) { | |
| 768 | + // Show scoped context - cursor is right at the end of the opening tag | |
| 769 | + const scopedContext = { | |
| 770 | + ...context, | |
| 771 | + currentArgIndex: context.args.length, | |
| 772 | + isInScopedContent: true, | |
| 773 | + scopedMacroName: scopedMacro.name, | |
| 774 | + }; | |
| 775 | + | |
| 776 | + const macroDef = macroSystem.registry.getPrimaryMacro(scopedMacro.name); | |
| 777 | + if (macroDef) { | |
| 778 | + const scopedOption = new EnhancedMacroAutoCompleteOption(macroDef, scopedContext); | |
| 779 | + scopedOption.valueProvider = () => ''; | |
| 780 | + scopedOption.makeSelectable = false; | |
| 781 | + | |
| 782 | + return new AutoCompleteNameResult( | |
| 783 | + scopedMacro.name, | |
| 784 | + macro.start + 2, | |
| 785 | + [scopedOption], | |
| 786 | + false, | |
| 787 | + ); | |
| 788 | + } | |
| 789 | + } | |
| 790 | + } | |
| 791 | + // Not a scoped macro, just clear arg highlighting | |
| 792 | + context.currentArgIndex = -1; | |
| 793 | + } | |
| 794 | + | |
| 795 | + // Use the identifier from context (handles whitespace and flags) | |
| 796 | + // Start position must be where the identifier actually begins (after whitespace/flags) | |
| 797 | + // so that the autocomplete range calculation works correctly | |
| 798 | + const identifier = context.identifier; | |
| 799 | + const identifierStartInText = macro.start + 2 + context.identifierStart; | |
| 800 | + | |
| 801 | + // Special case for {{if}} condition: use the condition text for matching/replacement | |
| 802 | + const isTypingIfCondition = context.identifier === 'if' && context.currentArgIndex === 0; | |
| 803 | + if (isTypingIfCondition) { | |
| 804 | + // Get the typed condition text and calculate its start position | |
| 805 | + const conditionText = context.args[0] || ''; | |
| 806 | + // Find where the condition argument starts in the macro text | |
| 807 | + const separatorMatch = macro.content.match(/^.*?if\s*(?:::?)\s*/); | |
| 808 | + const spaceMatch = macro.content.match(/^.*?if\s+/); | |
| 809 | + let conditionStartOffset; | |
| 810 | + if (separatorMatch) { | |
| 811 | + conditionStartOffset = separatorMatch[0].length; | |
| 812 | + } else if (spaceMatch) { | |
| 813 | + conditionStartOffset = spaceMatch[0].length; | |
| 814 | + } else { | |
| 815 | + conditionStartOffset = context.identifierStart + identifier.length; | |
| 816 | + } | |
| 817 | + const conditionStartInText = macro.start + 2 + conditionStartOffset; | |
| 818 | + | |
| 819 | + // Build if-condition options using macroContent for padding calculation | |
| 820 | + const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true }); | |
| 821 | + const options = buildIfConditionOptions(context, allMacros, macro.content); | |
| 822 | + | |
| 823 | + // For variable shorthand in {{if}} condition, adjust identifier and start position | |
| 824 | + // Same fix as for regular variable shorthands - identifier must be just the var name | |
| 825 | + // Also handle ! inversion prefix: !.var or !$var or !macroName | |
| 826 | + const trimmedCondition = conditionText.trim(); | |
| 827 | + const hasInversion = trimmedCondition.startsWith('!'); | |
| 828 | + // Trim whitespace after ! to handle "! $myvar" syntax | |
| 829 | + const conditionAfterInversion = hasInversion ? trimmedCondition.slice(1).trimStart() : trimmedCondition; | |
| 830 | + const isTypingVarShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$'); | |
| 831 | + let resultIdentifier = conditionText; | |
| 832 | + let resultStart = conditionStartInText; | |
| 833 | + | |
| 834 | + if (isTypingVarShorthand) { | |
| 835 | + // Identifier = just the variable name part (without prefix and without !) | |
| 836 | + resultIdentifier = conditionAfterInversion.slice(1); | |
| 837 | + // Start = after the ! (if any) and the prefix | |
| 838 | + const prefixChar = conditionAfterInversion[0]; | |
| 839 | + const prefixPosInCondition = conditionText.indexOf(prefixChar, hasInversion ? 1 : 0); | |
| 840 | + resultStart = conditionStartInText + prefixPosInCondition + 1; | |
| 841 | + } else if (hasInversion && conditionAfterInversion.length === 0) { | |
| 842 | + // Just ! (possibly with whitespace) typed - identifier should be empty so other options can match | |
| 843 | + resultIdentifier = ''; | |
| 844 | + // Start at end of actual condition text (including any whitespace after !) | |
| 845 | + // This ensures cursor is within the name range for filtering | |
| 846 | + resultStart = conditionStartInText + conditionText.length; | |
| 847 | + } else if (hasInversion && conditionAfterInversion.length > 0) { | |
| 848 | + // Typing a macro name after ! (e.g., !descr) - identifier should be just the macro name | |
| 849 | + resultIdentifier = conditionAfterInversion; | |
| 850 | + // Start = after the ! and any whitespace, at the beginning of the macro name | |
| 851 | + const macroNameStart = trimmedCondition.indexOf(conditionAfterInversion); | |
| 852 | + resultStart = conditionStartInText + macroNameStart; | |
| 853 | + } | |
| 854 | + | |
| 855 | + await onboardingExperimentalMacroEngine('{{if}} macro'); | |
| 856 | + | |
| 857 | + return new AutoCompleteNameResult( | |
| 858 | + resultIdentifier, | |
| 859 | + resultStart, | |
| 860 | + options, | |
| 861 | + false, | |
| 862 | + () => isTypingVarShorthand | |
| 863 | + ? 'Enter a variable name for the condition' | |
| 864 | + : 'Use {{macro}} syntax for dynamic conditions', | |
| 865 | + () => isTypingVarShorthand | |
| 866 | + ? 'Enter a variable name or select from the list' | |
| 867 | + : 'Enter a macro name or {{macro}} for the condition', | |
| 868 | + ); | |
| 869 | + } | |
| 870 | + | |
| 871 | + // Build regular macro options | |
| 872 | + /** @type {()=>string|undefined} */ | |
| 873 | + let makeNoMatchText = undefined; | |
| 874 | + /** @type {()=>string|undefined} */ | |
| 875 | + let makeNoOptionsText = undefined; | |
| 876 | + | |
| 877 | + const options = buildEnhancedMacroOptions(context, textUpToCursor); | |
| 878 | + | |
| 879 | + // For variable shorthands, calculate the correct identifier and start position | |
| 880 | + // based on what the user is currently typing (variable name, operator, or value) | |
| 881 | + let resultIdentifier = identifier; | |
| 882 | + let resultStart = identifierStartInText; | |
| 883 | + if (context.isVariableShorthand && context.variablePrefix) { | |
| 884 | + // Find where the prefix is in the macro content | |
| 885 | + const prefixIndex = macro.content.indexOf(context.variablePrefix); | |
| 886 | + | |
| 887 | + if (context.isTypingVariableName) { | |
| 888 | + // Typing variable name: identifier = variableName, start = after prefix | |
| 889 | + resultIdentifier = context.variableName; | |
| 890 | + if (prefixIndex >= 0) { | |
| 891 | + resultStart = macro.start + 2 + prefixIndex + 1; // +1 to skip the prefix | |
| 892 | + } | |
| 893 | + } else if (context.isTypingOperator) { | |
| 894 | + // Typing operator: identifier = partial operator or current operator, start = after variable name | |
| 895 | + resultIdentifier = context.partialOperator || context.variableOperator || ''; | |
| 896 | + // Use actual variableNameEnd position from parsing (accounts for whitespace) | |
| 897 | + resultStart = macro.start + 2 + context.variableNameEnd; | |
| 898 | + // Skip whitespace between variable name and operator | |
| 899 | + while (resultStart < cursorPos && /\s/.test(text[resultStart])) { | |
| 900 | + resultStart++; | |
| 901 | + } | |
| 902 | + } else if (context.isOperatorComplete) { | |
| 903 | + // Operator complete (++ or --) - show context but no value input needed | |
| 904 | + resultIdentifier = ''; | |
| 905 | + resultStart = cursorPos; // Cursor at end | |
| 906 | + } else if (context.hasInvalidTrailingChars) { | |
| 907 | + // Invalid chars after variable name: show the invalid chars for warning | |
| 908 | + resultIdentifier = context.invalidTrailingChars || ''; | |
| 909 | + // Use actual variableNameEnd position from parsing | |
| 910 | + resultStart = macro.start + 2 + context.variableNameEnd; | |
| 911 | + } else if (context.isTypingValue) { | |
| 912 | + // Typing value: identifier = value being typed, start = after operator | |
| 913 | + resultIdentifier = context.variableValue; | |
| 914 | + // Use actual operatorEnd position from parsing (accounts for whitespace) | |
| 915 | + resultStart = macro.start + 2 + context.variableOperatorEnd; | |
| 916 | + // Skip any whitespace between operator and value | |
| 917 | + while (resultStart < cursorPos && /\s/.test(text[resultStart])) { | |
| 918 | + resultStart++; | |
| 919 | + } | |
| 920 | + | |
| 921 | + makeNoMatchText = () => `Type any value you want to ${context.variableOperator == '+=' ? `add to the variable '${context.variableName}'` : `set the variable '${context.variableName}' to`}.`; | |
| 922 | + makeNoOptionsText = () => 'Enter a variable value'; | |
| 923 | + } else { | |
| 924 | + // Fallback: use variable name | |
| 925 | + resultIdentifier = context.variableName; | |
| 926 | + if (prefixIndex >= 0) { | |
| 927 | + resultStart = macro.start + 2 + prefixIndex + 1; | |
| 928 | + } | |
| 929 | + } | |
| 930 | + | |
| 931 | + if (!makeNoMatchText && !makeNoOptionsText) { | |
| 932 | + makeNoMatchText = () => 'Invalid syntax or variable name (must be alphanumeric, not ending in hyphen or underscore). Use a valid macro name or syntax.'; | |
| 933 | + makeNoOptionsText = () => 'Enter a variable name to create or use a new variable'; | |
| 934 | + } | |
| 935 | + } | |
| 936 | + | |
| 937 | + return new AutoCompleteNameResult( | |
| 938 | + resultIdentifier, | |
| 939 | + resultStart, | |
| 940 | + options, | |
| 941 | + false, | |
| 942 | + makeNoMatchText, | |
| 943 | + makeNoOptionsText, | |
| 944 | + ); | |
| 945 | +} | |
| 946 | + | |
| 947 | +/** | |
| 948 | + * Entry point for macro autocomplete in free text contexts. | |
| 949 | + * Finds the macro at cursor position and delegates to the shared builder. | |
| 950 | + * | |
| 951 | + * @param {string} text - The full text content. | |
| 952 | + * @param {number} cursorPos - The cursor position. | |
| 953 | + * @returns {Promise<AutoCompleteNameResult|null>} | |
| 954 | + */ | |
| 955 | +export async function getMacroAutoCompleteAt(text, cursorPos) { | |
| 956 | + const macro = findMacroAtCursor(text, cursorPos); | |
| 957 | + return buildMacroAutoCompleteResult(text, cursorPos, { macro }); | |
| 958 | +} | |
| @@ -2239,6 +2239,11 @@ export function initChatUtilities() { | ||
| 2239 | 2239 | wrapper.classList.add('flexFlowColumn', 'justifyCenter', 'alignitemscenter'); |
| 2240 | 2240 | const textarea = document.createElement('textarea'); |
| 2241 | 2241 | textarea.dataset.for = broId; |
| 2242 | + if (bro[0].dataset.macros !== undefined) { | |
| 2243 | + textarea.dataset.macros = bro[0].dataset.macros; | |
| 2244 | + textarea.dataset.macrosAutocomplete = 'always'; // Always show autocomplete in expanded editor | |
| 2245 | + textarea.dataset.macrosAutocompleteStyle = 'expanded'; // Use expanded autocomplete style | |
| 2246 | + } | |
| 2242 | 2247 | textarea.value = String(contentEditable ? bro[0].innerText : bro.val()); |
| 2243 | 2248 | textarea.classList.add('height100p', 'wide100p', 'maximized_textarea'); |
| 2244 | 2249 | bro.hasClass('monospace') && textarea.classList.add('monospace'); |
| @@ -326,6 +326,8 @@ export const power_user = { | ||
| 326 | 326 | right: AUTOCOMPLETE_WIDTH.CHAT, |
| 327 | 327 | }, |
| 328 | 328 | select: AUTOCOMPLETE_SELECT_KEY.TAB + AUTOCOMPLETE_SELECT_KEY.ENTER, |
| 329 | + /** Whether to show macro autocomplete in all macro-enabled fields (not just expanded editors) */ | |
| 330 | + showInAllMacroFields: false, | |
| 329 | 331 | }, |
| 330 | 332 | parser: { |
| 331 | 333 | /**@type {Object.<PARSER_FLAG,boolean>} */ |
| @@ -1590,6 +1592,9 @@ export async function loadPowerUserSettings(settings, data) { | ||
| 1590 | 1592 | if (power_user.stscript.autocomplete.select === undefined) { |
| 1591 | 1593 | power_user.stscript.autocomplete.select = defaultStscript.autocomplete.select; |
| 1592 | 1594 | } |
| 1595 | + if (power_user.stscript.autocomplete.showInAllMacroFields === undefined) { | |
| 1596 | + power_user.stscript.autocomplete.showInAllMacroFields = defaultStscript.autocomplete.showInAllMacroFields; | |
| 1597 | + } | |
| 1593 | 1598 | } |
| 1594 | 1599 | if (power_user.stscript.parser === undefined) { |
| 1595 | 1600 | power_user.stscript.parser = defaultStscript.parser; |
| @@ -1727,6 +1732,7 @@ export async function loadPowerUserSettings(settings, data) { | ||
| 1727 | 1732 | |
| 1728 | 1733 | $('#stscript_autocomplete_state').val(power_user.stscript.autocomplete.state).trigger('input'); |
| 1729 | 1734 | $('#stscript_autocomplete_autoHide').prop('checked', power_user.stscript.autocomplete.autoHide ?? false).trigger('input'); |
| 1735 | + $('#stscript_autocomplete_showInAllMacroFields').prop('checked', power_user.stscript.autocomplete.showInAllMacroFields ?? false).trigger('input'); | |
| 1730 | 1736 | $('#stscript_matching').val(power_user.stscript.matching ?? 'fuzzy'); |
| 1731 | 1737 | $('#stscript_autocomplete_style').val(power_user.stscript.autocomplete.style ?? 'theme'); |
| 1732 | 1738 | document.body.setAttribute('data-stscript-style', power_user.stscript.autocomplete.style); |
| @@ -4079,6 +4085,11 @@ jQuery(() => { | ||
| 4079 | 4085 | saveSettingsDebounced(); |
| 4080 | 4086 | }); |
| 4081 | 4087 | |
| 4088 | + $('#stscript_autocomplete_showInAllMacroFields').on('input', function () { | |
| 4089 | + power_user.stscript.autocomplete.showInAllMacroFields = !!$(this).prop('checked'); | |
| 4090 | + saveSettingsDebounced(); | |
| 4091 | + }); | |
| 4092 | + | |
| 4082 | 4093 | $('#stscript_matching').on('change', function () { |
| 4083 | 4094 | const value = $(this).find(':selected').val(); |
| 4084 | 4095 | power_user.stscript.matching = String(value); |
| @@ -16,30 +16,14 @@ import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNa | ||
| 16 | 16 | import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js'; |
| 17 | 17 | import { SlashCommandEnumValue } from './SlashCommandEnumValue.js'; |
| 18 | 18 | import { |
| 19 | 19 | EnhancedMacroAutoCompleteOptionfindUnclosedScopes, |
| 20 | 20 | MacroFlagAutoCompleteOptionbuildMacroAutoCompleteResult, |
| 21 | - MacroClosingTagAutoCompleteOption, | |
| 21 | +} from '../autocomplete/MacroAutoCompleteHelper.js'; | |
| 22 | - VariableShorthandAutoCompleteOption, | |
| 23 | - VariableShorthandDefinitions, | |
| 24 | - VariableNameAutoCompleteOption, | |
| 25 | - VariableOperatorAutoCompleteOption, | |
| 26 | - VariableOperatorDefinitions, | |
| 27 | - isValidVariableShorthandName, | |
| 28 | - parseMacroContext, | |
| 29 | - SimpleAutoCompleteOption, | |
| 30 | -} from '../autocomplete/EnhancedMacroAutoCompleteOption.js'; | |
| 31 | -import { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js'; | |
| 32 | -import { MacroParser } from '../macros/engine/MacroParser.js'; | |
| 33 | -import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js'; | |
| 34 | 22 | import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js'; |
| 35 | 23 | import { SlashCommandDebugController } from './SlashCommandDebugController.js'; |
| 36 | 24 | import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js'; |
| 37 | 25 | import { SlashCommandBreak } from './SlashCommandBreak.js'; |
| 38 | 26 | import { macros as macroSystemparseMacroContext } from '../macrosautocomplete/macro-systemEnhancedMacroAutoCompleteOption.js'; |
| 39 | -import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js'; | |
| 40 | -import { chat_metadata } from '/script.js'; | |
| 41 | -import { extension_settings } from '../extensions.js'; | |
| 42 | -import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnostics.js'; | |
| 43 | 27 | |
| 44 | 28 | /** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */ |
| 45 | 29 | /** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */ |
| @@ -507,250 +491,33 @@ export class SlashCommandParser { | ||
| 507 | 491 | ?? null |
| 508 | 492 | ; |
| 509 | 493 | if (childClosure !== null) return null; |
| 510 | - const macro = this.macroIndex.findLast(it=>it.start <= index && it.end >= index); | |
| 494 | + // Check if cursor is inside a macro | |
| 511 | - if (macro) { | |
| 495 | + const macroEntry = this.macroIndex.findLast(it=>it.start <= index && it.end >= index); | |
| 512 | - // Calculate cursor position within the macro for argument context | |
| 496 | + if (macroEntry) { | |
| 513 | - const cursorInMacro = index - macro.start - 2; // -2 for {{ | |
| 497 | + // Build macro info object for shared function | |
| 514 | 498 | const macroContent = text.slice(macromacroEntry.start + 2, macromacroEntry.end - (text.slice(macromacroEntry.end - 2, macromacroEntry.end) === '}}' ? 2 : 0)); |
| 515 | 499 | const contextmacro = parseMacroContext(macroContent, cursorInMacro);{ |
| 516 | - | |
| 500 | + start: macroEntry.start, | |
| 517 | - // Check if cursor is at/after the closing }} - macro syntax is complete | |
| 501 | + end: macroEntry.end, | |
| 518 | - const macroEndsBrackets = text.slice(macro.end - 2, macro.end) === '}}'; | |
| 502 | + content: macroContent, | |
| 519 | - const isCursorAtClosing = macroEndsBrackets && index >= macro.end - 1; | |
| 520 | - | |
| 521 | - if (isCursorAtClosing) { | |
| 522 | - // Cursor is at the closing }} - check if this is an unclosed scoped macro | |
| 523 | - const textUpToCursor = text.slice(0, index); | |
| 524 | - const unclosedScopes = this.#findUnclosedScopes(textUpToCursor); | |
| 525 | - | |
| 526 | - if (unclosedScopes.length > 0) { | |
| 527 | - const scopedMacro = unclosedScopes[unclosedScopes.length - 1]; | |
| 528 | - // Check if the current macro IS the unclosed scoped macro | |
| 529 | - if (scopedMacro.startOffset === macro.start) { | |
| 530 | - // Show scoped context - cursor is right at the end of the opening tag | |
| 531 | - const scopedContext = { | |
| 532 | - ...context, | |
| 533 | - currentArgIndex: context.args.length, // Next arg (scoped content) | |
| 534 | - isInScopedContent: true, | |
| 535 | - scopedMacroName: scopedMacro.name, | |
| 536 | 503 | }; |
| 537 | 504 | |
| 538 | - const macroDef = macroSystem.registry.getPrimaryMacro(scopedMacro.name); | |
| 505 | + // Use the shared macro autocomplete builder | |
| 539 | - if (macroDef) { | |
| 506 | + const result = await buildMacroAutoCompleteResult(text, index, { macro }); | |
| 540 | - const scopedOption = new EnhancedMacroAutoCompleteOption(macroDef, scopedContext); | |
| 507 | + if (result) return result; | |
| 541 | - scopedOption.valueProvider = () => ''; | |
| 542 | - | |
| 543 | - const result = new AutoCompleteNameResult( | |
| 544 | - scopedMacro.name, | |
| 545 | - macro.start + 2, | |
| 546 | - [scopedOption], | |
| 547 | - false, | |
| 548 | - ); | |
| 549 | - return result; | |
| 550 | - } | |
| 551 | - } | |
| 552 | - } | |
| 553 | - | |
| 554 | - // Not a scoped macro, just clear arg highlighting | |
| 555 | - context.currentArgIndex = -1; | |
| 556 | - } | |
| 557 | - | |
| 558 | - // Use the identifier from context (handles whitespace and flags) | |
| 559 | - // Start position must be where the identifier actually begins (after whitespace/flags) | |
| 560 | - // so that the autocomplete range calculation works correctly | |
| 561 | - const identifier = context.identifier; | |
| 562 | - const identifierStartInText = macro.start + 2 + context.identifierStart; | |
| 563 | - | |
| 564 | - // Use enhanced macro autocomplete when experimental engine is enabled | |
| 565 | - // Pass full text up to cursor for unclosed scope detection | |
| 566 | - const textUpToCursor = text.slice(0, index); | |
| 567 | - | |
| 568 | - // Special case for {{if}} condition: use the condition text for matching/replacement | |
| 569 | - const isTypingIfCondition = context.identifier === 'if' && context.currentArgIndex === 0; | |
| 570 | - if (isTypingIfCondition) { | |
| 571 | - // Get the typed condition text and calculate its start position | |
| 572 | - const conditionText = context.args[0] || ''; | |
| 573 | - // Find where the condition argument starts in the macro text | |
| 574 | - const separatorMatch = macroContent.match(/^.*?if\s*(?:::?)\s*/); | |
| 575 | - const spaceMatch = macroContent.match(/^.*?if\s+/); | |
| 576 | - let conditionStartOffset; | |
| 577 | - if (separatorMatch) { | |
| 578 | - conditionStartOffset = separatorMatch[0].length; | |
| 579 | - } else if (spaceMatch) { | |
| 580 | - conditionStartOffset = spaceMatch[0].length; | |
| 581 | - } else { | |
| 582 | - conditionStartOffset = context.identifierStart + identifier.length; | |
| 583 | - } | |
| 584 | - let conditionStartInText = macro.start + 2 + conditionStartOffset; | |
| 585 | - | |
| 586 | - // Build if-condition options using macroContent for padding calculation | |
| 587 | - const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true }); | |
| 588 | - const options = this.#buildIfConditionOptions(context, allMacros, macroContent); | |
| 589 | - | |
| 590 | - // For variable shorthand in {{if}} condition, adjust identifier and start position | |
| 591 | - // Same fix as for regular variable shorthands - identifier must be just the var name | |
| 592 | - // Also handle ! inversion prefix: !.var or !$var or !macroName | |
| 593 | - const trimmedCondition = conditionText.trim(); | |
| 594 | - const hasInversion = trimmedCondition.startsWith('!'); | |
| 595 | - // Trim whitespace after ! to handle "! $myvar" syntax | |
| 596 | - const conditionAfterInversion = hasInversion ? trimmedCondition.slice(1).trimStart() : trimmedCondition; | |
| 597 | - const isTypingVarShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$'); | |
| 598 | - let resultIdentifier = conditionText; | |
| 599 | - let resultStart = conditionStartInText; | |
| 600 | - | |
| 601 | - if (isTypingVarShorthand) { | |
| 602 | - // Identifier = just the variable name part (without prefix and without !) | |
| 603 | - resultIdentifier = conditionAfterInversion.slice(1); | |
| 604 | - // Start = after the ! (if any) and the prefix | |
| 605 | - const prefixChar = conditionAfterInversion[0]; | |
| 606 | - const prefixPosInCondition = conditionText.indexOf(prefixChar, hasInversion ? 1 : 0); | |
| 607 | - resultStart = conditionStartInText + prefixPosInCondition + 1; | |
| 608 | - } else if (hasInversion && conditionAfterInversion.length === 0) { | |
| 609 | - // Just ! (possibly with whitespace) typed - identifier should be empty so other options can match | |
| 610 | - resultIdentifier = ''; | |
| 611 | - // Start at end of actual condition text (including any whitespace after !) | |
| 612 | - // This ensures cursor is within the name range for filtering | |
| 613 | - resultStart = conditionStartInText + conditionText.length; | |
| 614 | - } else if (hasInversion && conditionAfterInversion.length > 0) { | |
| 615 | - // Typing a macro name after ! (e.g., !descr) - identifier should be just the macro name | |
| 616 | - resultIdentifier = conditionAfterInversion; | |
| 617 | - // Start = after the ! and any whitespace, at the beginning of the macro name | |
| 618 | - const macroNameStart = trimmedCondition.indexOf(conditionAfterInversion); | |
| 619 | - resultStart = conditionStartInText + macroNameStart; | |
| 620 | - } | |
| 621 | - | |
| 622 | - await onboardingExperimentalMacroEngine('{{if}} macro'); | |
| 623 | - | |
| 624 | - const result = new AutoCompleteNameResult( | |
| 625 | - resultIdentifier, | |
| 626 | - resultStart, | |
| 627 | - options, | |
| 628 | - false, | |
| 629 | - () => isTypingVarShorthand | |
| 630 | - ? 'Enter a variable name for the condition' | |
| 631 | - : 'Use {{macro}} syntax for dynamic conditions', | |
| 632 | - () => isTypingVarShorthand | |
| 633 | - ? 'Enter a variable name or select from the list' | |
| 634 | - : 'Enter a macro name or {{macro}} for the condition', | |
| 635 | - ); | |
| 636 | - return result; | |
| 637 | 508 | } |
| 638 | 509 | |
| 639 | - /** @type {()=>string|undefined} */ | |
| 510 | + // Check if cursor is in scoped content of an unclosed macro (not inside a macro) | |
| 640 | - let makeNoMatchText = undefined; | |
| 641 | - /** @type {()=>string|undefined} */ | |
| 642 | - let makeNoOptionsText = undefined; | |
| 643 | - | |
| 644 | - const options = this.#buildEnhancedMacroOptions(context, textUpToCursor); | |
| 645 | - | |
| 646 | - // For variable shorthands, calculate the correct identifier and start position | |
| 647 | - // based on what the user is currently typing (variable name, operator, or value) | |
| 648 | - let resultIdentifier = identifier; | |
| 649 | - let resultStart = identifierStartInText; | |
| 650 | - if (context.isVariableShorthand && context.variablePrefix) { | |
| 651 | - // Find where the prefix is in the macro content | |
| 652 | - const prefixIndex = macroContent.indexOf(context.variablePrefix); | |
| 653 | - | |
| 654 | - if (context.isTypingVariableName) { | |
| 655 | - // Typing variable name: identifier = variableName, start = after prefix | |
| 656 | - resultIdentifier = context.variableName; | |
| 657 | - if (prefixIndex >= 0) { | |
| 658 | - resultStart = macro.start + 2 + prefixIndex + 1; // +1 to skip the prefix | |
| 659 | - } | |
| 660 | - } else if (context.isTypingOperator) { | |
| 661 | - // Typing operator: identifier = partial operator or current operator, start = after variable name | |
| 662 | - resultIdentifier = context.partialOperator || context.variableOperator || ''; | |
| 663 | - // Use actual variableNameEnd position from parsing (accounts for whitespace) | |
| 664 | - resultStart = macro.start + 2 + context.variableNameEnd; | |
| 665 | - // Skip whitespace between variable name and operator | |
| 666 | - while (resultStart < index && /\s/.test(text[resultStart])) { | |
| 667 | - resultStart++; | |
| 668 | - } | |
| 669 | - } else if (context.isOperatorComplete) { | |
| 670 | - // Operator complete (++ or --) - show context but no value input needed | |
| 671 | - resultIdentifier = ''; | |
| 672 | - resultStart = index; // Cursor at end | |
| 673 | - } else if (context.hasInvalidTrailingChars) { | |
| 674 | - // Invalid chars after variable name: show the invalid chars for warning | |
| 675 | - resultIdentifier = context.invalidTrailingChars || ''; | |
| 676 | - // Use actual variableNameEnd position from parsing | |
| 677 | - resultStart = macro.start + 2 + context.variableNameEnd; | |
| 678 | - } else if (context.isTypingValue) { | |
| 679 | - // Typing value: identifier = value being typed, start = after operator | |
| 680 | - resultIdentifier = context.variableValue; | |
| 681 | - // Use actual operatorEnd position from parsing (accounts for whitespace) | |
| 682 | - resultStart = macro.start + 2 + context.variableOperatorEnd; | |
| 683 | - // Skip any whitespace between operator and value | |
| 684 | - while (resultStart < index && /\s/.test(text[resultStart])) { | |
| 685 | - resultStart++; | |
| 686 | - } | |
| 687 | - | |
| 688 | - makeNoMatchText = () => `Type any value you want to ${context.variableOperator == '+=' ? `add to the variable '${context.variableName}'` : `set the variable '${context.variableName}' to`}.`; | |
| 689 | - makeNoOptionsText = () => 'Enter a variable value'; | |
| 690 | - } else { | |
| 691 | - // Fallback: use variable name | |
| 692 | - resultIdentifier = context.variableName; | |
| 693 | - if (prefixIndex >= 0) { | |
| 694 | - resultStart = macro.start + 2 + prefixIndex + 1; | |
| 695 | - } | |
| 696 | - } | |
| 697 | - | |
| 698 | - if (!makeNoMatchText && !makeNoOptionsText) { | |
| 699 | - makeNoMatchText = () => 'Invalid syntax or variable name (must be alphanumeric, not ending in hyphen or underscore). Use a valid macro name or syntax.'; | |
| 700 | - makeNoOptionsText = () => 'Enter a variable name to create or use a new variable'; | |
| 701 | - } | |
| 702 | - } | |
| 703 | - | |
| 704 | - const result = new AutoCompleteNameResult( | |
| 705 | - resultIdentifier, | |
| 706 | - resultStart, | |
| 707 | - options, | |
| 708 | - false, | |
| 709 | - makeNoMatchText, | |
| 710 | - makeNoOptionsText, | |
| 711 | - ); | |
| 712 | - return result; | |
| 713 | - } | |
| 714 | - | |
| 715 | - // Check if cursor is in scoped content of an unclosed macro | |
| 716 | 511 | const textUpToCursor = text.slice(0, index); |
| 717 | 512 | const unclosedScopes = this.#findUnclosedScopes(textUpToCursor); |
| 718 | 513 | if (unclosedScopes.length > 0) { |
| 719 | - const scopedMacro = unclosedScopes[unclosedScopes.length - 1]; | |
| 514 | + // Use the shared macro autocomplete builder for scoped content | |
| 720 | - // Find the original macro in macroIndex to get full info | |
| 515 | + const result = await buildMacroAutoCompleteResult(text, index, { | |
| 721 | - const originalMacro = this.macroIndex.find(it => it.start === scopedMacro.startOffset); | |
| 516 | + macro: null, | |
| 722 | - if (originalMacro) { | |
| 517 | + textUpToCursor, | |
| 723 | - // Parse the original macro content to get base context | |
| 518 | + unclosedScopes, | |
| 724 | - const macroContent = text.slice(originalMacro.start + 2, originalMacro.end - 2); | |
| 519 | + }); | |
| 725 | - const baseContext = parseMacroContext(macroContent, macroContent.length); | |
| 520 | + if (result) return result; | |
| 726 | - | |
| 727 | - // Create a scoped context - show next arg as current (the scoped content) | |
| 728 | - const scopedContext = { | |
| 729 | - ...baseContext, | |
| 730 | - currentArgIndex: baseContext.args.length, // Next arg index (the scoped one) | |
| 731 | - isInScopedContent: true, | |
| 732 | - scopedMacroName: scopedMacro.name, | |
| 733 | - }; | |
| 734 | - | |
| 735 | - await onboardingExperimentalMacroEngine('scoped macros'); | |
| 736 | - | |
| 737 | - // Only show the scoped macro's details - no list of other macros | |
| 738 | - // This creates a "details only" view showing the scoped arg being typed | |
| 739 | - const macroDef = macroSystem.registry.getPrimaryMacro(scopedMacro.name); | |
| 740 | - if (macroDef) { | |
| 741 | - const scopedOption = new EnhancedMacroAutoCompleteOption(macroDef, scopedContext); | |
| 742 | - // Mark as non-insertable - we're just showing details | |
| 743 | - scopedOption.valueProvider = () => ''; | |
| 744 | - | |
| 745 | - const result = new AutoCompleteNameResult( | |
| 746 | - scopedMacro.name, // Use macro name so it shows as "match" | |
| 747 | - originalMacro.start + 2, // Point to original macro | |
| 748 | - [scopedOption], | |
| 749 | - false, | |
| 750 | - ); | |
| 751 | - return result; | |
| 752 | - } | |
| 753 | - } | |
| 754 | 521 | } |
| 755 | 522 | if (executor.name == ':') { |
| 756 | 523 | const options = this.scopeIndex[this.commandIndex.indexOf(executor)] |
| @@ -785,544 +552,6 @@ export class SlashCommandParser { | ||
| 785 | 552 | } |
| 786 | 553 | |
| 787 | 554 | /** |
| 788 | - * Builds enhanced macro autocomplete options from the MacroRegistry. | |
| 789 | - * When in the flags area (before identifier), includes flag options. | |
| 790 | - * When typing arguments (after ::), prioritizes the exact macro match. | |
| 791 | - * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context | |
| 792 | - * @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection. | |
| 793 | - * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} | |
| 794 | - */ | |
| 795 | - #buildEnhancedMacroOptions(context, textUpToCursor = '') { | |
| 796 | - /** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} */ | |
| 797 | - const options = []; | |
| 798 | - | |
| 799 | - // Handle variable shorthand mode | |
| 800 | - if (context.isVariableShorthand) { | |
| 801 | - return this.#buildVariableShorthandOptions(context); | |
| 802 | - } | |
| 803 | - | |
| 804 | - // Check for unclosed scoped macros and suggest closing tags first | |
| 805 | - const unclosedScopes = this.#findUnclosedScopes(textUpToCursor); | |
| 806 | - if (unclosedScopes.length > 0) { | |
| 807 | - // Suggest closing the innermost (last) unclosed scope first | |
| 808 | - const innermostScope = unclosedScopes[unclosedScopes.length - 1]; | |
| 809 | - // Preserve whitespace padding from the opening tag | |
| 810 | - // Pass currentPadding so the closing tag can replace user-typed whitespace with the target padding | |
| 811 | - const closingOption = new MacroClosingTagAutoCompleteOption(innermostScope.name, { | |
| 812 | - paddingBefore: innermostScope.paddingBefore, | |
| 813 | - paddingAfter: innermostScope.paddingAfter, | |
| 814 | - currentPadding: context.paddingBefore, | |
| 815 | - }); | |
| 816 | - options.push(closingOption); | |
| 817 | - | |
| 818 | - // If inside a scoped {{if}}, also suggest {{else}} | |
| 819 | - if (innermostScope.name === 'if') { | |
| 820 | - const macroDef = macroSystem.registry.getPrimaryMacro('else'); | |
| 821 | - const elseOption = new EnhancedMacroAutoCompleteOption(macroDef); | |
| 822 | - elseOption.sortPriority = 2; | |
| 823 | - options.push(elseOption); | |
| 824 | - } | |
| 825 | - } | |
| 826 | - | |
| 827 | - // If cursor is in the flags area (before identifier starts), include flag options | |
| 828 | - if (context.isInFlagsArea) { | |
| 829 | - // Build flag options with priority-based sorting | |
| 830 | - // Last typed flag has highest priority (1), other flags have lower priority (10) | |
| 831 | - // Already-typed flags (except last) are hidden from the list | |
| 832 | - const lastTypedFlag = context.flags.length > 0 ? context.flags[context.flags.length - 1] : null; | |
| 833 | - | |
| 834 | - // Add last typed flag with high priority (so it appears at top) | |
| 835 | - if (lastTypedFlag) { | |
| 836 | - const lastFlagDef = MacroFlagDefinitions.get(lastTypedFlag); | |
| 837 | - if (lastFlagDef) { | |
| 838 | - const lastFlagOption = new MacroFlagAutoCompleteOption(lastFlagDef); | |
| 839 | - // Mark as already typed - valueProvider returns empty so it doesn't re-insert | |
| 840 | - lastFlagOption.valueProvider = () => ''; | |
| 841 | - // High priority to appear at top (after closing tags at 1) | |
| 842 | - lastFlagOption.sortPriority = 2; | |
| 843 | - options.push(lastFlagOption); | |
| 844 | - } | |
| 845 | - } | |
| 846 | - | |
| 847 | - // Add flags that haven't been typed yet (skip already-typed ones except last) | |
| 848 | - for (const [symbol, flagDef] of MacroFlagDefinitions) { | |
| 849 | - // Skip the last typed flag (already added above) and other already-typed flags | |
| 850 | - if (context.flags.includes(symbol)) { | |
| 851 | - continue; | |
| 852 | - } | |
| 853 | - const flagOption = new MacroFlagAutoCompleteOption(flagDef); | |
| 854 | - | |
| 855 | - // Define whether this flag is selectable (and at the top), based on being implemented, and closing actually being relevant | |
| 856 | - let isSelectable = flagDef.implemented; | |
| 857 | - if (flagDef.type === MacroFlagType.CLOSING_BLOCK && !unclosedScopes.length) isSelectable = false; | |
| 858 | - if (!isSelectable) { | |
| 859 | - flagOption.valueProvider = () => ''; | |
| 860 | - } | |
| 861 | - // Normal flag priority | |
| 862 | - flagOption.sortPriority = isSelectable ? 10 : 12; | |
| 863 | - options.push(flagOption); | |
| 864 | - } | |
| 865 | - | |
| 866 | - // Add variable shorthand prefix options (. for local, $ for global) | |
| 867 | - // These allow users to type variable shorthands instead of macro names | |
| 868 | - for (const [, varShorthandDef] of VariableShorthandDefinitions) { | |
| 869 | - const varOption = new VariableShorthandAutoCompleteOption(varShorthandDef); | |
| 870 | - varOption.sortPriority = 8; // Between implemented flags (10) and unimplemented (12) | |
| 871 | - options.push(varOption); | |
| 872 | - } | |
| 873 | - } | |
| 874 | - | |
| 875 | - // Get all macros from the registry (excluding hidden aliases) | |
| 876 | - const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true }); | |
| 877 | - | |
| 878 | - // If we're typing arguments (after ::), only show the context to the matching macro | |
| 879 | - const isTypingArgs = context.currentArgIndex >= 0; | |
| 880 | - | |
| 881 | - // Check if we're inside a scoped {{if}} for {{else}} selectability | |
| 882 | - const isInsideScopedIf = unclosedScopes.some(scope => scope.name === 'if'); | |
| 883 | - | |
| 884 | - for (const macro of allMacros) { | |
| 885 | - // Check if this macro matches the typed identifier | |
| 886 | - const isExactMatch = macro.name === context.identifier; | |
| 887 | - const isAliasMatch = macro.aliasOf === context.identifier; | |
| 888 | - | |
| 889 | - // Only pass context to the macro that matches the identifier being typed | |
| 890 | - // This ensures argument hints only show for the relevant macro | |
| 891 | - /** @type {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} */ | |
| 892 | - let macroContext = (isExactMatch || isAliasMatch) ? context : null; | |
| 893 | - | |
| 894 | - // If no context, we pass some options for additional details though | |
| 895 | - if (!macroContext) { | |
| 896 | - macroContext = /** @type {EnhancedMacroAutoCompleteOptions} */ ({ | |
| 897 | - paddingAfter: context.paddingBefore, // Match whitespace before the macro - will only be used if the macro gets auto-closed | |
| 898 | - flags: context.flags, | |
| 899 | - currentFlag: context.currentFlag, | |
| 900 | - fullText: context.fullText, | |
| 901 | - }); | |
| 902 | - } | |
| 903 | - | |
| 904 | - const option = new EnhancedMacroAutoCompleteOption(macro, macroContext); | |
| 905 | - | |
| 906 | - // {{else}} is only selectable inside a scoped {{if}} block | |
| 907 | - // Outside of {{if}}, it should appear in the list but not be tab-completable | |
| 908 | - if (macro.name === 'else' && !isInsideScopedIf) { | |
| 909 | - option.valueProvider = () => ''; | |
| 910 | - option.makeSelectable = false; | |
| 911 | - } | |
| 912 | - | |
| 913 | - // When typing arguments, prioritize exact matches by putting them first | |
| 914 | - if (isTypingArgs && (isExactMatch || isAliasMatch)) { | |
| 915 | - options.unshift(option); | |
| 916 | - } else { | |
| 917 | - options.push(option); | |
| 918 | - } | |
| 919 | - } | |
| 920 | - | |
| 921 | - return options; | |
| 922 | - } | |
| 923 | - | |
| 924 | - /** | |
| 925 | - * Builds autocomplete options for variable shorthand syntax (.varName or $varName). | |
| 926 | - * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context | |
| 927 | - * @param {Object} [opts] - Optional configuration. | |
| 928 | - * @param {boolean} [opts.forIfCondition=false] - If true, options are for {{if}} condition (closes with }}). | |
| 929 | - * @param {string} [opts.paddingAfter=''] - Whitespace to add before closing }}. | |
| 930 | - * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} | |
| 931 | - */ | |
| 932 | - #buildVariableShorthandOptions(context, opts = {}) { | |
| 933 | - const { forIfCondition = false, paddingAfter = '' } = opts; | |
| 934 | - /** @type {(VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} */ | |
| 935 | - const options = []; | |
| 936 | - | |
| 937 | - const isLocal = context.variablePrefix === '.'; | |
| 938 | - const scope = isLocal ? 'local' : 'global'; | |
| 939 | - | |
| 940 | - // Always show the typed variable prefix as a non-completable option (like flags do) | |
| 941 | - // This allows the details panel to show information about the prefix | |
| 942 | - const prefixDef = VariableShorthandDefinitions.get(context.variablePrefix); | |
| 943 | - if (prefixDef) { | |
| 944 | - const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef); | |
| 945 | - prefixOption.valueProvider = () => ''; // Already typed, don't re-insert | |
| 946 | - prefixOption.makeSelectable = false; | |
| 947 | - prefixOption.sortPriority = 1; // Show at top | |
| 948 | - prefixOption.matchProvider = () => true; // Always show regardless of filtering | |
| 949 | - options.push(prefixOption); | |
| 950 | - } | |
| 951 | - | |
| 952 | - // If typing the variable name, suggest existing variables | |
| 953 | - // Get existing variable names from the appropriate scope | |
| 954 | - // Filter to only include names that are valid for shorthand syntax | |
| 955 | - const existingVariables = this.#getVariableNames(scope) | |
| 956 | - .filter(name => isValidVariableShorthandName(name)); | |
| 957 | - | |
| 958 | - // Check if the typed variable name exactly matches an existing variable | |
| 959 | - const variableNameMatchesExisting = context.variableName.length > 0 && existingVariables.includes(context.variableName); | |
| 960 | - | |
| 961 | - if (context.isTypingVariableName) { | |
| 962 | - // Add existing variables that match the typed name | |
| 963 | - for (const varName of existingVariables) { | |
| 964 | - const option = new VariableNameAutoCompleteOption(varName, scope, false); | |
| 965 | - // For {{if}} condition, provide full value with closing braces | |
| 966 | - if (forIfCondition) { | |
| 967 | - option.valueProvider = () => `${varName}${paddingAfter}}}`; // No variable prefix, as that has been written and committed already. | |
| 968 | - option.makeSelectable = true; | |
| 969 | - } | |
| 970 | - // Variables matching the typed prefix get higher priority | |
| 971 | - if (varName.startsWith(context.variableName)) { | |
| 972 | - option.sortPriority = 3; | |
| 973 | - } else { | |
| 974 | - option.sortPriority = 10; | |
| 975 | - } | |
| 976 | - options.push(option); | |
| 977 | - } | |
| 978 | - | |
| 979 | - // If typing a name that doesn't exist, offer to create a new variable | |
| 980 | - // But if the name is invalid for shorthand syntax, show a warning instead | |
| 981 | - if (context.variableName.length > 0 && !existingVariables.includes(context.variableName)) { | |
| 982 | - const isInvalid = !isValidVariableShorthandName(context.variableName); | |
| 983 | - const newVarOption = new VariableNameAutoCompleteOption(context.variableName, scope, true, isInvalid); | |
| 984 | - newVarOption.sortPriority = isInvalid ? 2 : 4; // Invalid names get higher priority to show warning | |
| 985 | - if (isInvalid) { | |
| 986 | - // Make it non-selectable since it can't be used | |
| 987 | - newVarOption.valueProvider = () => ''; | |
| 988 | - newVarOption.makeSelectable = false; | |
| 989 | - } else if (forIfCondition) { | |
| 990 | - // For {{if}} condition, provide full value with closing braces | |
| 991 | - newVarOption.valueProvider = () => `${context.variablePrefix}${context.variableName}${paddingAfter}}}`; | |
| 992 | - } | |
| 993 | - options.push(newVarOption); | |
| 994 | - } | |
| 995 | - | |
| 996 | - // If the typed variable name exactly matches an existing variable, also show operators | |
| 997 | - // This allows users to see available operators without having to type a space first | |
| 998 | - if (variableNameMatchesExisting) { | |
| 999 | - for (const [, operatorDef] of VariableOperatorDefinitions) { | |
| 1000 | - const opOption = new VariableOperatorAutoCompleteOption(operatorDef); | |
| 1001 | - opOption.sortPriority = 6; // Lower priority than variable suggestions | |
| 1002 | - opOption.matchProvider = () => true; // Always show | |
| 1003 | - // IMPORTANT: Operators should INSERT after variable name, not replace it | |
| 1004 | - // Use replacementStartOffset to shift insertion point past the variable name | |
| 1005 | - opOption.replacementStartOffset = context.variableName.length; | |
| 1006 | - options.push(opOption); | |
| 1007 | - } | |
| 1008 | - } | |
| 1009 | - } | |
| 1010 | - | |
| 1011 | - // If there are invalid trailing characters after the variable name, show a warning | |
| 1012 | - if (context.hasInvalidTrailingChars) { | |
| 1013 | - // Show the full invalid name (variableName + invalidTrailingChars) with a warning | |
| 1014 | - const fullInvalidName = context.variableName + (context.invalidTrailingChars || ''); | |
| 1015 | - const invalidOption = new VariableNameAutoCompleteOption( | |
| 1016 | - fullInvalidName, | |
| 1017 | - scope, | |
| 1018 | - false, | |
| 1019 | - true, // isInvalidName - triggers warning display | |
| 1020 | - ); | |
| 1021 | - invalidOption.valueProvider = () => ''; // Don't insert anything | |
| 1022 | - invalidOption.makeSelectable = false; | |
| 1023 | - invalidOption.sortPriority = 2; | |
| 1024 | - invalidOption.matchProvider = () => true; // Always show | |
| 1025 | - options.push(invalidOption); | |
| 1026 | - // Return early - don't show operators when syntax is invalid | |
| 1027 | - return options; | |
| 1028 | - } | |
| 1029 | - | |
| 1030 | - // If ready for operator (after variable name), suggest operators | |
| 1031 | - if (context.isTypingOperator) { | |
| 1032 | - // Show the current variable name as context (already typed) | |
| 1033 | - const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false); | |
| 1034 | - varNameOption.valueProvider = () => ''; // Already typed, don't re-insert | |
| 1035 | - varNameOption.sortPriority = 2; | |
| 1036 | - varNameOption.matchProvider = () => true; // Always show | |
| 1037 | - options.push(varNameOption); | |
| 1038 | - | |
| 1039 | - // Then show available operators, filtered by partial prefix if any | |
| 1040 | - // Also filter by current complete operator to show longer variants (e.g., > shows >=) | |
| 1041 | - const partialOp = context.partialOperator || ''; | |
| 1042 | - const currentOp = context.variableOperator || ''; | |
| 1043 | - const filterPrefix = partialOp || currentOp; | |
| 1044 | - for (const [, operatorDef] of VariableOperatorDefinitions) { | |
| 1045 | - // Filter by operator prefix if user is typing one | |
| 1046 | - // This allows typing ">" to show both ">" and ">=" | |
| 1047 | - if (filterPrefix && !operatorDef.symbol.startsWith(filterPrefix)) { | |
| 1048 | - continue; | |
| 1049 | - } | |
| 1050 | - const opOption = new VariableOperatorAutoCompleteOption(operatorDef); | |
| 1051 | - // Exact match gets higher priority | |
| 1052 | - opOption.sortPriority = operatorDef.symbol === currentOp ? 4 : 5; | |
| 1053 | - // Already-typed operator is non-selectable | |
| 1054 | - if (operatorDef.symbol === currentOp) { | |
| 1055 | - opOption.valueProvider = () => ''; | |
| 1056 | - } | |
| 1057 | - // Always match operators when showing operator suggestions | |
| 1058 | - opOption.matchProvider = () => true; | |
| 1059 | - options.push(opOption); | |
| 1060 | - } | |
| 1061 | - } | |
| 1062 | - | |
| 1063 | - // If typing value (after = or +=), no autocomplete needed - freeform text | |
| 1064 | - // But we show the current context for reference (greyed out, non-selectable) | |
| 1065 | - if (context.isTypingValue && !context.isTypingOperator) { | |
| 1066 | - // Show the current variable name as context (non-selectable) | |
| 1067 | - const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false); | |
| 1068 | - varNameOption.valueProvider = () => ''; // Context only | |
| 1069 | - varNameOption.makeSelectable = false; | |
| 1070 | - varNameOption.sortPriority = 2; | |
| 1071 | - varNameOption.matchProvider = () => true; // Always show | |
| 1072 | - options.push(varNameOption); | |
| 1073 | - | |
| 1074 | - // Show the operator that was used (non-selectable) | |
| 1075 | - if (context.variableOperator) { | |
| 1076 | - const opDef = VariableOperatorDefinitions.get(context.variableOperator); | |
| 1077 | - if (opDef) { | |
| 1078 | - const opOption = new VariableOperatorAutoCompleteOption(opDef); | |
| 1079 | - opOption.valueProvider = () => ''; // Already typed | |
| 1080 | - opOption.makeSelectable = false; | |
| 1081 | - opOption.sortPriority = 3; | |
| 1082 | - opOption.matchProvider = () => true; // Always show | |
| 1083 | - options.push(opOption); | |
| 1084 | - } | |
| 1085 | - } | |
| 1086 | - } | |
| 1087 | - | |
| 1088 | - // If operator is complete (++ or --), show context without value input (non-selectable) | |
| 1089 | - if (context.isOperatorComplete && !context.isTypingOperator) { | |
| 1090 | - // Show the current variable name as context (non-selectable) | |
| 1091 | - const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false); | |
| 1092 | - varNameOption.valueProvider = () => ''; // Context only | |
| 1093 | - varNameOption.makeSelectable = false; | |
| 1094 | - varNameOption.sortPriority = 2; | |
| 1095 | - varNameOption.matchProvider = () => true; // Always show | |
| 1096 | - options.push(varNameOption); | |
| 1097 | - | |
| 1098 | - // Show the operator that was used (non-selectable) | |
| 1099 | - if (context.variableOperator) { | |
| 1100 | - const opDef = VariableOperatorDefinitions.get(context.variableOperator); | |
| 1101 | - if (opDef) { | |
| 1102 | - const opOption = new VariableOperatorAutoCompleteOption(opDef); | |
| 1103 | - opOption.valueProvider = () => ''; // Already typed | |
| 1104 | - opOption.makeSelectable = false; | |
| 1105 | - opOption.sortPriority = 3; | |
| 1106 | - opOption.matchProvider = () => true; // Always show | |
| 1107 | - options.push(opOption); | |
| 1108 | - } | |
| 1109 | - } | |
| 1110 | - } | |
| 1111 | - | |
| 1112 | - return options; | |
| 1113 | - } | |
| 1114 | - | |
| 1115 | - /** | |
| 1116 | - * Gets variable names from the specified scope. | |
| 1117 | - * @param {'local'|'global'} scope - The variable scope. | |
| 1118 | - * @returns {string[]} Array of variable names. | |
| 1119 | - */ | |
| 1120 | - #getVariableNames(scope) { | |
| 1121 | - try { | |
| 1122 | - // Import chat_metadata and extension_settings dynamically to avoid circular deps | |
| 1123 | - // These are the same sources used by commonEnumProviders.variables | |
| 1124 | - if (scope === 'local') { | |
| 1125 | - // Local variables are in chat_metadata.variables | |
| 1126 | - return Object.keys(chat_metadata?.variables ?? {}); | |
| 1127 | - } else { | |
| 1128 | - // Global variables are in extension_settings.variables.global | |
| 1129 | - return Object.keys(extension_settings?.variables?.global ?? {}); | |
| 1130 | - } | |
| 1131 | - } catch { | |
| 1132 | - return []; | |
| 1133 | - } | |
| 1134 | - } | |
| 1135 | - | |
| 1136 | - /** | |
| 1137 | - * Builds autocomplete options for {{if}} condition - shows zero-arg macros as shorthand. | |
| 1138 | - * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context | |
| 1139 | - * @param {import('../macros/engine/MacroRegistry.js').MacroDefinition[]} allMacros | |
| 1140 | - * @param {string} macroInnerText - The text inside the macro braces (e.g., " if pers" from "{{ if pers"). | |
| 1141 | - * @returns {AutoCompleteOption[]} | |
| 1142 | - */ | |
| 1143 | - #buildIfConditionOptions(context, allMacros, macroInnerText) { | |
| 1144 | - /** @type {AutoCompleteOption[]} */ | |
| 1145 | - const options = []; | |
| 1146 | - | |
| 1147 | - // Calculate padding from the original macro text for matching whitespace on completion | |
| 1148 | - // e.g., " if pers" -> leading padding = " " (whitespace before 'if', used before '}}') | |
| 1149 | - const leadingMatch = macroInnerText.match(/^(\s*)/); | |
| 1150 | - const paddingAfter = leadingMatch ? leadingMatch[1] : ''; | |
| 1151 | - | |
| 1152 | - // Get the condition text being typed (trimmed for detection) | |
| 1153 | - const conditionText = (context.args[0] || '').trim(); | |
| 1154 | - | |
| 1155 | - // Check for inversion prefix (!) - also trim whitespace after ! | |
| 1156 | - const hasInversionPrefix = conditionText.startsWith('!'); | |
| 1157 | - const conditionAfterInversion = hasInversionPrefix ? conditionText.slice(1).trimStart() : conditionText; | |
| 1158 | - | |
| 1159 | - const inversionOption = new SimpleAutoCompleteOption({ | |
| 1160 | - name: '!', | |
| 1161 | - symbol: '🔁', | |
| 1162 | - description: 'Invert condition (NOT)', | |
| 1163 | - detailedDescription: 'Inverts the condition result. If the condition is truthy, it becomes falsy, and vice versa.<br><br>Example: <code>{{if !myVar}}</code> executes when <code>myVar</code> is empty or zero.', | |
| 1164 | - type: 'inverse', | |
| 1165 | - }); | |
| 1166 | - | |
| 1167 | - // Check if condition starts with a variable shorthand prefix (with or without !) | |
| 1168 | - const isTypingVariableShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$'); | |
| 1169 | - | |
| 1170 | - if (isTypingVariableShorthand) { | |
| 1171 | - // User is typing a variable shorthand - reuse #buildVariableShorthandOptions | |
| 1172 | - const prefix = /** @type {'.'|'$'} */ (conditionAfterInversion[0]); | |
| 1173 | - const varNameTyped = conditionAfterInversion.slice(1); // Variable name after the prefix | |
| 1174 | - | |
| 1175 | - // If inverted, show the ! as non-selectable context | |
| 1176 | - if (hasInversionPrefix) { | |
| 1177 | - inversionOption.valueProvider = () => ''; // Already typed | |
| 1178 | - inversionOption.makeSelectable = false; | |
| 1179 | - inversionOption.sortPriority = 0; | |
| 1180 | - options.push(inversionOption); | |
| 1181 | - } | |
| 1182 | - | |
| 1183 | - // Create a synthetic context for #buildVariableShorthandOptions | |
| 1184 | - /** @type {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} */ | |
| 1185 | - const varContext = { | |
| 1186 | - ...context, | |
| 1187 | - isVariableShorthand: true, | |
| 1188 | - variablePrefix: prefix, | |
| 1189 | - variableName: varNameTyped, | |
| 1190 | - isTypingVariableName: true, | |
| 1191 | - isTypingOperator: false, | |
| 1192 | - isTypingValue: false, | |
| 1193 | - isOperatorComplete: false, | |
| 1194 | - hasInvalidTrailingChars: false, | |
| 1195 | - variableOperator: null, | |
| 1196 | - variableValue: '', | |
| 1197 | - }; | |
| 1198 | - | |
| 1199 | - const varOptions = this.#buildVariableShorthandOptions(varContext, { forIfCondition: true, paddingAfter }); | |
| 1200 | - options.push(...varOptions); | |
| 1201 | - return options; | |
| 1202 | - } | |
| 1203 | - | |
| 1204 | - // Not typing a variable shorthand - show macro options, variable shorthand prefixes, and inversion | |
| 1205 | - | |
| 1206 | - // Show ! inversion option at the top when nothing typed, or keep it visible (non-selectable) if already typed | |
| 1207 | - if (conditionText.length === 0) { | |
| 1208 | - // Nothing typed - offer ! as selectable option | |
| 1209 | - inversionOption.valueProvider = () => '!'; | |
| 1210 | - inversionOption.makeSelectable = true; | |
| 1211 | - inversionOption.sortPriority = -1; // Show at very top | |
| 1212 | - options.push(inversionOption); | |
| 1213 | - } else if (hasInversionPrefix && conditionAfterInversion.length === 0) { | |
| 1214 | - // Just ! typed - show it as non-selectable context, then show macro names and variable prefixes | |
| 1215 | - inversionOption.valueProvider = () => ''; // Already typed | |
| 1216 | - inversionOption.makeSelectable = false; | |
| 1217 | - inversionOption.sortPriority = -1; | |
| 1218 | - options.push(inversionOption); | |
| 1219 | - } | |
| 1220 | - | |
| 1221 | - // Add variable shorthand prefix options when no content typed yet (or just ! typed) | |
| 1222 | - if (conditionAfterInversion.length === 0) { | |
| 1223 | - for (const [, prefixDef] of VariableShorthandDefinitions) { | |
| 1224 | - const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef); | |
| 1225 | - // Complete with just the prefix symbol | |
| 1226 | - prefixOption.valueProvider = () => prefixDef.type; | |
| 1227 | - prefixOption.makeSelectable = true; | |
| 1228 | - prefixOption.sortPriority = 0; // Show at top | |
| 1229 | - options.push(prefixOption); | |
| 1230 | - } | |
| 1231 | - } | |
| 1232 | - | |
| 1233 | - // Add zero-arg macros as condition shorthand options | |
| 1234 | - for (const macro of allMacros) { | |
| 1235 | - // Only include macros that require zero arguments (can be auto-resolved) | |
| 1236 | - if (macro.minArgs !== 0) continue; | |
| 1237 | - | |
| 1238 | - // Skip internal/utility macros that don't make sense as conditions | |
| 1239 | - if (['else', 'noop', 'trim', '//'].includes(macro.name)) continue; | |
| 1240 | - | |
| 1241 | - const option = new EnhancedMacroAutoCompleteOption(macro, { | |
| 1242 | - noBraces: true, | |
| 1243 | - paddingAfter, | |
| 1244 | - closeWithBraces: true, | |
| 1245 | - }); | |
| 1246 | - options.push(option); | |
| 1247 | - } | |
| 1248 | - | |
| 1249 | - return options; | |
| 1250 | - } | |
| 1251 | - | |
| 1252 | - /** | |
| 1253 | - * Finds unclosed scoped macros in the text up to cursor position. | |
| 1254 | - * Uses the MacroParser and MacroCstWalker for accurate analysis. | |
| 1255 | - * | |
| 1256 | - * @param {string} textUpToCursor - The document text up to the cursor position. | |
| 1257 | - * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>} | |
| 1258 | - */ | |
| 1259 | - #findUnclosedScopes(textUpToCursor) { | |
| 1260 | - if (!textUpToCursor) return []; | |
| 1261 | - | |
| 1262 | - try { | |
| 1263 | - // Parse the document to get the CST | |
| 1264 | - const { cst } = MacroParser.parseDocument(textUpToCursor); | |
| 1265 | - if (!cst) return []; | |
| 1266 | - | |
| 1267 | - // Use the CST walker to find unclosed scopes | |
| 1268 | - return MacroCstWalker.findUnclosedScopes({ text: textUpToCursor, cst }); | |
| 1269 | - } catch { | |
| 1270 | - // If parsing fails (incomplete input), fall back to simple regex approach | |
| 1271 | - return this.#findUnclosedScopesRegex(textUpToCursor); | |
| 1272 | - } | |
| 1273 | - } | |
| 1274 | - | |
| 1275 | - /** | |
| 1276 | - * Fallback regex-based approach for finding unclosed scopes. | |
| 1277 | - * Used when the parser fails on incomplete input. | |
| 1278 | - * | |
| 1279 | - * @param {string} text - The text to analyze. | |
| 1280 | - * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>} | |
| 1281 | - */ | |
| 1282 | - #findUnclosedScopesRegex(text) { | |
| 1283 | - // Regex to find macro openings and closings, capturing whitespace padding | |
| 1284 | - // Group 1: padding after {{, Group 2: optional /, Group 3: macro name | |
| 1285 | - const macroPattern = /\{\{(\s*)(\/?)([\w-]+)/g; | |
| 1286 | - const stack = []; | |
| 1287 | - | |
| 1288 | - let match; | |
| 1289 | - while ((match = macroPattern.exec(text)) !== null) { | |
| 1290 | - const paddingBefore = match[1]; | |
| 1291 | - const isClosing = match[2] === '/'; | |
| 1292 | - const name = match[3]; | |
| 1293 | - | |
| 1294 | - if (isClosing) { | |
| 1295 | - // Pop matching opener (case-insensitive) | |
| 1296 | - if (stack.length > 0 && stack[stack.length - 1].name.toLowerCase() === name.toLowerCase()) { | |
| 1297 | - stack.pop(); | |
| 1298 | - } | |
| 1299 | - } else { | |
| 1300 | - // Check if macro can accept scoped content | |
| 1301 | - const macroDef = macroSystem.registry.getPrimaryMacro(name); | |
| 1302 | - if (macroDef && macroDef.maxArgs > 0) { | |
| 1303 | - // Try to find closing }} to extract trailing whitespace | |
| 1304 | - let paddingAfter = ''; | |
| 1305 | - const afterMatch = text.slice(match.index + match[0].length); | |
| 1306 | - const closingMatch = afterMatch.match(/^[^}]*?(\s*)\}\}/); | |
| 1307 | - if (closingMatch) { | |
| 1308 | - paddingAfter = closingMatch[1]; | |
| 1309 | - } | |
| 1310 | - | |
| 1311 | - stack.push({ | |
| 1312 | - name, | |
| 1313 | - startOffset: match.index, | |
| 1314 | - endOffset: match.index + match[0].length, | |
| 1315 | - paddingBefore, | |
| 1316 | - paddingAfter, | |
| 1317 | - }); | |
| 1318 | - } | |
| 1319 | - } | |
| 1320 | - } | |
| 1321 | - | |
| 1322 | - return stack; | |
| 1323 | - } | |
| 1324 | - | |
| 1325 | - /** | |
| 1326 | 555 | * Moves the index <length> number of characters forward and returns the last character taken. |
| 1327 | 556 | * @param {number} length Number of characters to take. |
| 1328 | 557 | * @param {boolean} keep Whether to add the characters to the kept text. |
| @@ -2844,6 +2844,7 @@ function enableKeysInputHelper({ template, entry, entryPropName, originalDataVal | ||
| 2844 | 2844 | const isFancyInput = !isMobile() && !power_user.wi_key_input_plaintext; |
| 2845 | 2845 | const input = isFancyInput ? template.find(`select[name="${entryPropName}"]`) : template.find(`textarea[name="${entryPropName}"]`); |
| 2846 | 2846 | input.data('uid', entry.uid); |
| 2847 | + input[0].dataset.macros = ''; // active | |
| 2847 | 2848 | input.on('click', function (event) { |
| 2848 | 2849 | event.stopPropagation(); |
| 2849 | 2850 | }); |
| @@ -3579,6 +3580,7 @@ export async function getWorldEntry(name, data, entry) { | ||
| 3579 | 3580 | const contentInput = editTemplate.find('textarea[name="content"]'); |
| 3580 | 3581 | contentInput.data('uid', entry.uid); |
| 3581 | 3582 | contentInput.attr('id', contentInputId); |
| 3583 | + contentInput[0].dataset.macros = ''; // active | |
| 3582 | 3584 | contentInput.on('input', async function (_, { skipCount, noSave } = {}) { |
| 3583 | 3585 | const uid = $(this).data('uid'); |
| 3584 | 3586 | const value = $(this).val(); |
| @@ -1654,6 +1654,13 @@ button { | ||
| 1654 | 1654 | width: 50vw; |
| 1655 | 1655 | } |
| 1656 | 1656 | |
| 1657 | + /* Small style variant for macro autocomplete (33vw, max 700px) */ | |
| 1658 | + &[data-macros-autocomplete-style="small"] .autoComplete { | |
| 1659 | + width: 33vw; | |
| 1660 | + max-width: 700px; | |
| 1661 | + min-width: 250px; | |
| 1662 | + } | |
| 1663 | + | |
| 1657 | 1664 | &:after { |
| 1658 | 1665 | content: ""; |
| 1659 | 1666 | flex: 1 1 0; |
| @@ -1706,6 +1713,38 @@ button { | ||
| 1706 | 1713 | width: 25vw; |
| 1707 | 1714 | } |
| 1708 | 1715 | |
| 1716 | + /* Small style variant for macro autocomplete (33vw, max 700px) */ | |
| 1717 | + &[data-macros-autocomplete-style="small"] .autoComplete-details { | |
| 1718 | + width: 33vw; | |
| 1719 | + max-width: 700px; | |
| 1720 | + min-width: 250px; | |
| 1721 | + } | |
| 1722 | + | |
| 1723 | + &[data-macros-autocomplete-style="small"] { | |
| 1724 | + &.left { | |
| 1725 | + &:before { | |
| 1726 | + flex: 0 1 calc(var(--targetOffset) * 1px - 33vw); | |
| 1727 | + } | |
| 1728 | + | |
| 1729 | + &:after { | |
| 1730 | + flex: 1 0 auto; | |
| 1731 | + max-width: 33vw; | |
| 1732 | + } | |
| 1733 | + } | |
| 1734 | + | |
| 1735 | + &.right { | |
| 1736 | + &:before { | |
| 1737 | + flex: 0 0 calc(var(--targetOffset) * 1px + 33vw); | |
| 1738 | + } | |
| 1739 | + } | |
| 1740 | + | |
| 1741 | + &.full { | |
| 1742 | + .autoComplete-details { | |
| 1743 | + max-width: 33vw; | |
| 1744 | + } | |
| 1745 | + } | |
| 1746 | + } | |
| 1747 | + | |
| 1709 | 1748 | &.left { |
| 1710 | 1749 | &:before { |
| 1711 | 1750 | flex: 0 1 calc(var(--targetOffset) * 1px - 25vw); |