Markdown hotkeys for textareas (#2800) * initial commit * dont hijack all ctrl keybinds * change strikethrough bind, convert to subscribable class, target key textareas * better early return, hotkey reversiblility * possible undo solution, key checks to switch * execCommand alternate, perfect Undo * format full word when caret is in the middle of a word * double backticks do nothing, dummy. * ctrl+K for ....'K'ode snippet... * remove console logs * Add new hotkeys to help * Allow hotkeys in message edit textarea * add markdown hotkey help text * help text addition to mention hotkeys work in message edits * add markdown hotkeys to WI entry content box * disengage if alt/win pressed, universal prevent default * disengage if shiftKey pressed * re-allow shift for one special case * add MD hotkeys toggle in user settings * add markdown enabled icon on relevant textareas when appropriate * Add icon to help * Uniform formatting * Add opacity to icon --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

b376ea884b56f310f6cb6382f8aa7883fc81801e

RossAscends <124905043+RossAscends@users.noreply.github.com>

Signed
6 files changed, +229 -26Ignore whitespace
public/css/toggle-dependent.css+4 -0
@@ -455,3 +455,7 @@ body.expandMessageActions .mes .mes_buttons .extraMesButtonsHint {
455455#smooth_streaming:checked~#smooth_streaming_speed_control {
456456 display: block;
457457}
458+
459+.mdhotkey_icon {
460+ opacity: 0.6;
461+}
public/index.html+21 -14
@@ -4033,10 +4033,17 @@
40334033 <input id="world_import_dialog" type="checkbox" />
40344034 <small data-i18n="Lorebook Import Dialog">Lorebook Import Dialog</small>
40354035 </label>
40364036 <label class="checkbox_label" for=" enable_auto_select_input" title="Enable auto-select of input text in some text fields when clicking/selecting them. Applies to popup input textboxes, and possible other custom input fields." data-i18n="[title]Enable auto-select of input text in some text fields when clicking/selecting them. Applies to popup input textboxes, and possible other custom input fields.">
40374037 <input id="enable_auto_select_input" type="checkbox" />
40384038 <small data-i18n="Auto-select Input Text">Auto-select Input Text</small>
40394039 </label>
4040+ <label class="checkbox_label alignItemsCenter" for="enable_md_hotkeys" title="Enable hotkeys for inserting markdown format characters in certain text input boxes. See '/help hotkeys'.">
4041+ <input id="enable_md_hotkeys" type="checkbox" />
4042+ <small>
4043+ <span data-i18n="Markdown Hotkeys">Markdown Hotkeys</span>
4044+ <i class="fa-brands fa-markdown"></i>
4045+ </small>
4046+ </label>
40404047 <label class="checkbox_label" for="restore_user_input" title="Restore unsaved user input on page refresh." data-i18n="[title]Restore unsaved user input on page refresh">
40414048 <input id="restore_user_input" type="checkbox" />
40424049 <small data-i18n="Restore User Input">Restore User Input</small>
@@ -4720,7 +4727,7 @@
47204727 <hr>
47214728 <div id="description_div" class="title_restorable">
47224729 <div class="flex-container alignitemscenter">
47234730 <span data-i18n="Character Description" class="mdhotkey_location">Description</span>
47244731 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="description_textarea" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
47254732 <a href="https://docs.sillytavern.app/usage/core-concepts/characterdesign/#character-description" class="notes-link" target="_blank">
47264733 <span class="fa-solid fa-circle-question note-link-span"></span>
@@ -4734,7 +4741,7 @@
47344741 </span>
47354742 </div>
47364743 </div>
47374744 <textarea id="description_textarea" class="mdHotkeys" 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>
47384745 <div class="extension_token_counter">
47394746 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="description_textarea" data-token-permanent="true">counting...</span>
47404747 </div>
@@ -4742,7 +4749,7 @@
47424749 <div id="firstMessageWrapper" class="flex-container flexFlowColumn flex1">
47434750 <div id="first_message_div" class="title_restorable">
47444751 <div class="flex-container alignitemscenter flex1">
47454752 <span data-i18n="First message" class="mdhotkey_location">First message</span>
47464753 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="firstmessage_textarea" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
47474754 <a href="https://docs.sillytavern.app/usage/core-concepts/characterdesign/#first-message" class="notes-link" target="_blank">
47484755 <span class="fa-solid fa-circle-question note-link-span"></span>
@@ -4754,7 +4761,7 @@
47544761 </span>
47554762 </div>
47564763 </div>
47574764 <textarea class="mdHotkeys" id="firstmessage_textarea" 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>
47584765 <div class="extension_token_counter">
47594766 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="firstmessage_textarea">counting...</span>
47604767 </div>
@@ -5112,7 +5119,7 @@
51125119 </div>
51135120 <hr>
51145121 <div id="personality_div">
5115- <h4>
5122+ <h4 class="flex-container alignItemsBaseline">
51165123 <span data-i18n="Personality summary">Personality summary</span>
51175124 <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>
51185125 </h4>
@@ -5122,7 +5129,7 @@
51225129 </div>
51235130 </div>
51245131 <div id="scenario_div">
5125- <h4>
5132+ <h4 class="flex-container alignItemsBaseline">
51265133 <span data-i18n="Scenario">Scenario</span>
51275134 <a href="https://docs.sillytavern.app/usage/core-concepts/characterdesign/#scenario" class="notes-link" target="_blank">
51285135 <span class="fa-solid fa-circle-question note-link-span"></span>
@@ -5179,10 +5186,10 @@
51795186 <hr>
51805187 <div id="mes_example_div" class="flex-container flexFlowColumn">
51815188 <div>
51825189 <h4 class="flex-container gap5px alignItemsBaseline"><span data-i18n="Examples of dialogue" class="mdhotkey_location">Examples of dialogue</span></h4>
51835190 <h5 data-i18n="Important to set the character's writing style.">Important to set the character's writing style. <a href="https://docs.sillytavern.app/usage/core-concepts/characterdesign/#examples-of-dialogue" class="notes-link" target="_blank"><span class="fa-solid fa-circle-question note-link-span"></span></a></h5>
51845191 </div>
51855192 <textarea id="mes_example_textarea" class="flexGrow mdHotkeys" name="mes_example" 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 &lt;START&gt; on a new line.)" form="form_create" rows="6"></textarea>
51865193 <div class="extension_token_counter">
51875194 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="mes_example_textarea">counting...</span>
51885195 </div>
@@ -5477,7 +5484,7 @@
54775484 <label for="content ">
54785485 <small>
54795486 <span class="alignitemscenter flex-container flexnowrap wide100p justifySpaceBetween">
54805487 <span data-i18n="Content" class="alignitemscenter flex-container flexNoGap mdhotkey_location">
54815488 Content
54825489 </span>
54835490 <span>
@@ -5512,7 +5519,7 @@
55125519 </span>
55135520 </small>
55145521 </label>
55155522 <textarea class="text_pole mdHotkeys" name="content" rows="8" data-i18n="[placeholder]What this keyword should mean to the AI, sent verbatim" placeholder="What this keyword should mean to the AI, sent verbatim"></textarea>
55165523 </div>
55175524 </div>
55185525 <div class="world_entry_thin_controls commentContainer">
@@ -5987,7 +5994,7 @@
59875994 <div id="alternate_greetings_template" class="template_element">
59885995 <div class="alternate_grettings flexFlowColumn flex-container">
59895996 <div class="title_restorable">
59905997 <h3><span data-i18n="Alternate Greetings" class="mdhotkey_location">Alternate Greetings</span></h3>
59915998 <div title="Add" class="menu_button fa-solid fa-plus add_alternate_greeting" data-i18n="[title]Add"></div>
59925999 </div>
59936000 <small class="justifyLeft" data-i18n="Alternate_Greetings_desc">
@@ -6008,7 +6015,7 @@
60086015 <strong><span data-i18n="Alternate Greeting #">Alternate Greeting #</span><span class="greeting_index"></span></strong>
60096016 <div class="menu_button fa-solid fa-trash-alt delete_alternate_greeting"></div>
60106017 </div>
60116018 <textarea name="alternate_greetings" 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)" class="text_pole textarea_compact alternate_greeting_text mdHotkeys" value="" autocomplete="off" rows="16"></textarea>
60126019 </div>
60136020 </div>
60146021
@@ -6392,7 +6399,7 @@
63926399 <div id="leftSendForm" class="alignContentCenter">
63936400 <div id="options_button" class="fa-solid fa-bars interactable"></div>
63946401 </div>
63956402 <textarea id="send_textarea" name="text" class="mdHotkeys" data-i18n="[no_connection_text]Not connected to API!;[connected_text]Type a message, or /? for help" placeholder="Not connected to API!" no_connection_text="Not connected to API!" connected_text="Type a message, or /? for help"></textarea>
63966403 <div id="rightSendForm" class="alignContentCenter">
63976404 <div id="stscript_continue" title="Continue script execution" class="stscript_btn stscript_continue" data-i18n="[title]Continue script execution">
63986405 <i class="fa-solid fa-play"></i>
public/script.js+3 -1
@@ -242,6 +242,7 @@ import { INTERACTABLE_CONTROL_CLASS, initKeyboard } from './scripts/keyboard.js'
242242import { initDynamicStyles } from './scripts/dynamic-styles.js';
243243import { SlashCommandEnumValue, enumTypes } from './scripts/slash-commands/SlashCommandEnumValue.js';
244244import { commonEnumProviders, enumIcons } from './scripts/slash-commands/SlashCommandCommonEnumsProvider.js';
245+import { initInputMarkdown } from './scripts/input-md-formatting.js';
245246import { AbortReason } from './scripts/util/AbortReason.js';
246247
247248//exporting functions and vars for mods
@@ -951,6 +952,7 @@ async function firstLoadInit() {
951952 initStats();
952953 initCfg();
953954 initLogprobs();
955+ initInputMarkdown();
954956 doDailyExtensionUpdatesCheck();
955957 await hideLoader();
956958 await fixViewport();
@@ -10200,7 +10202,7 @@ jQuery(async function () {
1020010202 .closest('.mes_block')
1020110203 .find('.mes_text')
1020210204 .append(
1020310205 '<textarea id=\'curEditTextarea\' class=\'edit_textarea mdHotkeys\' style=\'max-width:auto;\'></textarea>',
1020410206 );
1020510207 $('#curEditTextarea').val(text);
1020610208 let edit_textarea = $(this)
public/scripts/input-md-formatting.js+152 -0
@@ -0,0 +1,152 @@
1+import { power_user } from './power-user.js';
2+
3+export function initInputMarkdown() {
4+ $(document).on('keydown', 'textarea.mdHotkeys', function (e) {
5+ if (!power_user.enable_md_hotkeys) { return; }
6+
7+ // Ensure that the element is a textarea
8+ let textarea = this;
9+ if (!(textarea instanceof HTMLTextAreaElement)) {
10+ return;
11+ }
12+
13+ // Early return on only control or no control, alt key, and win/cmd key
14+ if (e.key === 'Control' || !e.ctrlKey || e.altKey || e.metaKey || (e.shiftKey && !(e.ctrlKey && e.shiftKey && e.code === 'Backquote'))) {
15+ return;
16+ }
17+ let charsToAdd = '';
18+ let possiblePreviousFormattingMargin = 1;
19+
20+ switch (true) {
21+ case e.ctrlKey && e.shiftKey && e.code === 'Backquote':
22+ e.preventDefault();
23+ e.stopPropagation();
24+ charsToAdd = '~~';
25+ possiblePreviousFormattingMargin = 2;
26+ break;
27+ case e.ctrlKey && e.code === 'KeyB':
28+ e.preventDefault();
29+ e.stopPropagation();
30+ charsToAdd = '**';
31+ possiblePreviousFormattingMargin = 2;
32+ break;
33+ case e.ctrlKey && e.code === 'KeyI':
34+ e.preventDefault();
35+ e.stopPropagation();
36+ charsToAdd = '*';
37+ break;
38+ case e.ctrlKey && e.code === 'KeyU':
39+ e.preventDefault();
40+ e.stopPropagation();
41+ charsToAdd = '__';
42+ possiblePreviousFormattingMargin = 2;
43+ break;
44+ case e.ctrlKey && e.code === 'KeyK':
45+ e.preventDefault();
46+ e.stopPropagation();
47+ charsToAdd = '`';
48+ break;
49+ default:
50+ return; // Early return if no key matches
51+ }
52+
53+ let selectedText = '';
54+ let start = textarea.selectionStart;
55+ let end = textarea.selectionEnd;
56+ let beforeCaret = textarea.value.substring(start - 1, start);
57+ let afterCaret = textarea.value.substring(end, end + 1);
58+ let isTextSelected = (start !== end);
59+ let cursorShift = charsToAdd.length;
60+ let selectedTextandPossibleFormatting = textarea.value.substring(start - possiblePreviousFormattingMargin, end + possiblePreviousFormattingMargin).trim();
61+
62+ if (isTextSelected) { //if text is selected
63+ selectedText = textarea.value.substring(start, end);
64+ if (selectedTextandPossibleFormatting === charsToAdd + selectedText + charsToAdd) {
65+ // If the selected text is already formatted, remove the formatting
66+
67+ let expandedStart = start - charsToAdd.length;
68+ let expandedEnd = end + charsToAdd.length;
69+
70+ // Ensure expanded range is within the bounds of the text
71+ if (expandedStart < 0) expandedStart = 0;
72+ if (expandedEnd > textarea.value.length) expandedEnd = textarea.value.length;
73+
74+ // Select the expanded range
75+ textarea.setSelectionRange(expandedStart, expandedEnd);
76+
77+ // Replace the expanded selection with the original selected text
78+ document.execCommand('insertText', false, selectedText);
79+ // Adjust cursor position
80+ cursorShift = -charsToAdd.length;
81+ } else {
82+ // Add formatting to the selected text
83+ let possibleAddedSpace = '';
84+ if (selectedText.endsWith(' ')) {
85+ possibleAddedSpace = ' ';
86+ selectedText = selectedText.substring(0, selectedText.length - 1);
87+ end--; // Adjust the end index since we removed the space
88+ }
89+ // To add the formatting, we need to select the text first
90+ textarea.focus();
91+ document.execCommand('insertText', false, charsToAdd + selectedText + charsToAdd + possibleAddedSpace);
92+ }
93+ } else {// No text is selected
94+ //check 1 character before and after the cursor for non-space characters
95+
96+ if (beforeCaret !== ' ' && afterCaret !== ' ' && afterCaret !== '' && beforeCaret !== '') { //look for caret in the middle of a word
97+ //expand the selection range until the next space on both sides
98+ let midCaretExpandedStart = start - 1;
99+ let midCaretExpandedEnd = end + 1;
100+ while (midCaretExpandedStart > 0 && textarea.value.substring(midCaretExpandedStart - 1, midCaretExpandedStart) !== ' ') {
101+ midCaretExpandedStart--;
102+ }
103+ while (midCaretExpandedEnd < textarea.value.length && textarea.value.substring(midCaretExpandedEnd, midCaretExpandedEnd + 1) !== ' ') {
104+ midCaretExpandedEnd++;
105+ }
106+ //make a selection of the discovered word
107+ textarea.setSelectionRange(midCaretExpandedStart, midCaretExpandedEnd);
108+ //set variables for comparison
109+ let discoveredWordWithPossibleFormatting = textarea.value.substring(midCaretExpandedStart, midCaretExpandedEnd).trim();
110+ let discoveredWord = '';
111+
112+ if (discoveredWordWithPossibleFormatting.endsWith(charsToAdd) && discoveredWordWithPossibleFormatting.startsWith(charsToAdd)) {
113+ discoveredWord = textarea.value.substring(midCaretExpandedStart + charsToAdd.length, midCaretExpandedEnd - charsToAdd.length).trim();
114+ } else {
115+ discoveredWord = textarea.value.substring(midCaretExpandedStart, midCaretExpandedEnd).trim();
116+ }
117+
118+ if (charsToAdd + discoveredWord + charsToAdd === discoveredWordWithPossibleFormatting) {
119+
120+ // Replace the expanded selection with the original discovered word
121+ textarea.focus();
122+ document.execCommand('insertText', false, discoveredWord);
123+ // Adjust cursor position
124+ cursorShift = -charsToAdd.length;
125+ } else { //format did not previously exist, so add it
126+ textarea.focus();
127+ document.execCommand('insertText', false, charsToAdd + discoveredWord + charsToAdd);
128+ }
129+
130+
131+ } else { //caret is not inside a word, so just add the formatting
132+ textarea.focus();
133+ textarea.setSelectionRange(start, end);
134+ selectedText = textarea.value.substring(start, end);
135+ document.execCommand('insertText', false, charsToAdd + selectedText + charsToAdd);
136+ }
137+ }
138+
139+ // Manually trigger the 'input' event to make undo/redo work
140+ let event = new Event('input', { bubbles: true });
141+ textarea.dispatchEvent(event); // This notifies the browser of a change, allowing undo/redo to function.
142+
143+ // Update the cursor position
144+ if (isTextSelected) {
145+ textarea.selectionStart = start + cursorShift;
146+ textarea.selectionEnd = start + cursorShift + selectedText.length;
147+ } else {
148+ textarea.selectionStart = start + cursorShift;
149+ textarea.selectionEnd = start + cursorShift;
150+ }
151+ });
152+}
public/scripts/power-user.js+20 -0
@@ -200,6 +200,7 @@ let power_user = {
200200 relaxed_api_urls: false,
201201 world_import_dialog: true,
202202 enable_auto_select_input: false,
203+ enable_md_hotkeys: false,
203204 tag_import_setting: tag_import_setting.ASK,
204205 disable_group_trimming: false,
205206 single_line: false,
@@ -1452,6 +1453,7 @@ async function loadPowerUserSettings(settings, data) {
14521453 $('#relaxed_api_urls').prop('checked', power_user.relaxed_api_urls);
14531454 $('#world_import_dialog').prop('checked', power_user.world_import_dialog);
14541455 $('#enable_auto_select_input').prop('checked', power_user.enable_auto_select_input);
1456+ $('#enable_md_hotkeys').prop('checked', power_user.enable_md_hotkeys);
14551457 $('#trim_spaces').prop('checked', power_user.trim_spaces);
14561458 $('#continue_on_send').prop('checked', power_user.continue_on_send);
14571459 $('#quick_continue').prop('checked', power_user.quick_continue);
@@ -1601,6 +1603,17 @@ async function loadPowerUserSettings(settings, data) {
16011603 switchSpoilerMode();
16021604 loadMovingUIState();
16031605 loadCharListState();
1606+ toggleMDHotkeyIconDisplay();
1607+}
1608+
1609+function toggleMDHotkeyIconDisplay() {
1610+ if (power_user.enable_md_hotkeys) {
1611+ $('.mdhotkey_location').each(function () {
1612+ $(this).parent().append('<i class="fa-brands fa-markdown mdhotkey_icon"></i>');
1613+ });
1614+ } else {
1615+ $('.mdhotkey_icon').remove();
1616+ }
16041617}
16051618
16061619function loadCharListState() {
@@ -3610,6 +3623,13 @@ $(document).ready(() => {
36103623 saveSettingsDebounced();
36113624 });
36123625
3626+ $('#enable_md_hotkeys').on('input', function () {
3627+ const value = !!$(this).prop('checked');
3628+ power_user.enable_md_hotkeys = value;
3629+ toggleMDHotkeyIconDisplay();
3630+ saveSettingsDebounced();
3631+ });
3632+
36133633 $('#spoiler_free_mode').on('input', function () {
36143634 power_user.spoiler_free_mode = !!$(this).prop('checked');
36153635 switchSpoilerMode();
public/scripts/templates/hotkeys.html+29 -11
@@ -1,13 +1,31 @@
1-<span data-i18n="help_hotkeys_0">Hotkeys/Keybinds</span>:
1+<div>
2+ <strong data-i18n="help_hotkeys_0">Chat Hotkeys</strong>
3+</div>
24<ul>
35 <li><ttkbd data-i18n="help_hotkeys_1">Up</ttkbd> = <span data-i18n="help_hotkeys_2">Edit last message in chat</span></li>
46 <li><ttkbd data-i18n="help_hotkeys_3">Ctrl+Up</ttkbd> = <span data-i18n="help_hotkeys_4">Edit last USER message in chat</span></li>
57 <li><ttkbd data-i18n="help_hotkeys_5">Left</ttkbd> = <span data-i18n="help_hotkeys_6">swipe left</span></li>
68 <li><ttkbd data-i18n="help_hotkeys_7">Right</ttkbd> = <span data-i18n="help_hotkeys_8">swipe right (NOTE: swipe hotkeys are disabled when chatbar has something typed into it)</span></li>
79 <li><ttkbd data-i18n="help_hotkeys_9">Enter</ttkbd> <span data-i18n="help_hotkeys_10">(with chat bar selected)</span> = <span data-i18n="help_hotkeys_10_1">send your message to AI</span></li>
810 <li><ttkbd data-i18n="help_hotkeys_11">Ctrl+Enter</ttkbd> = <span data-i18n="help_hotkeys_12">Regenerate the last AI response</span></li>
911 <li><ttkbd data-i18n="help_hotkeys_13">Alt+Enter</ttkbd> = <span data-i18n="help_hotkeys_14">Continue the last AI response</span></li>
1012 <li><ttkbd data-i18n="help_hotkeys_15">Escape</ttkbd> = <span data-i18n="help_hotkeys_16">stop AI response generation, close UI panels, cancel message edit</span></li>
1113 <li><ttkbd data-i18n="help_hotkeys_17">Ctrl+Shift+Up</ttkbd> = <span data-i18n="help_hotkeys_18">Scroll to context line</span></li>
1214 <li><ttkbd data-i18n="help_hotkeys_19">Ctrl+Shift+Down</ttkbd> = <span data-i18n="help_hotkeys_20">Scroll chat to bottom</span></li>
15+</ul>
16+<div>
17+ <strong>Markdown Hotkeys</strong>
18+</div>
19+<div>
20+ <small>
21+ <span>Works in the chatbar and textareas marked with this icon:</span>
22+ <code><i class="fa-brands fa-markdown"></i></code>
23+ </small>
24+</div>
25+<ul>
26+ <li><kbd>Ctrl+B</kbd> = <span>**bold**</span></li>
27+ <li><kbd>Ctrl+I</kbd> = <span>*italic*</span></li>
28+ <li><kbd>Ctrl+U</kbd> = <span>__underline__</span></li>
29+ <li><kbd>Ctrl+K</kbd> = <span>`inline code`</span></li>
30+ <li><kbd>Ctrl+Shift+~</kbd> = <span>~~strikethrough~~</span></li>
1331</ul>