Blame Raw
Cohee · e3f41666 · · 411 lines (14.7 KB)
1 contributor
1import { getRequestHeaders, substituteParams } from '../../../../script.js';
2import { Popup, POPUP_RESULT, POPUP_TYPE } from '../../../popup.js';
3import { executeSlashCommandsOnChatInput, executeSlashCommandsWithOptions } from '../../../slash-commands.js';
4import { SlashCommandScope } from '../../../slash-commands/SlashCommandScope.js';
5import { SlashCommandParser } from '../../../slash-commands/SlashCommandParser.js';
6import { debounceAsync, warn } from '../index.js';
7import { QuickReply } from './QuickReply.js';
8
9export class QuickReplySet {
10 /**@type {QuickReplySet[]}*/ static list = [];
11
12 /**
13 * @param {Partial<QuickReplySet>} props
14 * @returns {QuickReplySet}
15 */
16 static from(props) {
17 props.qrList = []; //props.qrList?.map(it=>QuickReply.from(it));
18 const instance = Object.assign(new this(), props);
19 // instance.init();
20 return instance;
21 }
22
23 /**
24 * @param {string} name - name of the QuickReplySet
25 */
26 static get(name) {
27 return this.list.find(it => it.name == name);
28 }
29
30 /**@type {string}*/ name;
31 /**@type {'global'|'chat'|'character'}*/ scope = 'global';
32 /**@type {boolean}*/ disableSend = false;
33 /**@type {boolean}*/ placeBeforeInput = false;
34 /**@type {boolean}*/ injectInput = false;
35 /**@type {string}*/ color = 'transparent';
36 /**@type {boolean}*/ onlyBorderColor = false;
37 /**@type {QuickReply[]}*/ qrList = [];
38 /**@type {number}*/ idIndex = 0;
39 /**@type {boolean}*/ isDeleted = false;
40 /**@type {function}*/ save;
41 /**@type {HTMLElement}*/ dom;
42 /**@type {HTMLElement}*/ settingsDom;
43
44 constructor() {
45 this.save = debounceAsync(() => this.performSave(), 200);
46 }
47
48 init() {
49 this.qrList.forEach(qr => this.hookQuickReply(qr));
50 }
51
52 unrender() {
53 this.dom?.remove();
54 this.dom = null;
55 }
56 render() {
57 this.unrender();
58 if (!this.dom) {
59 const root = document.createElement('div'); {
60 this.dom = root;
61 root.classList.add('qr--buttons');
62 this.updateColor();
63 this.qrList.filter(qr => !qr.isHidden).forEach(qr => {
64 root.append(qr.render());
65 });
66 }
67 }
68 return this.dom;
69 }
70 rerender() {
71 if (!this.dom) return;
72 this.dom.innerHTML = '';
73 this.qrList.filter(qr => !qr.isHidden).forEach(qr => {
74 this.dom.append(qr.render());
75 });
76 }
77 updateColor() {
78 if (!this.dom) return;
79 if (this.color && this.color != 'transparent') {
80 this.dom.style.setProperty('--qr--color', this.color);
81 this.dom.classList.add('qr--color');
82 if (this.onlyBorderColor) {
83 this.dom.classList.add('qr--borderColor');
84 } else {
85 this.dom.classList.remove('qr--borderColor');
86 }
87 } else {
88 this.dom.style.setProperty('--qr--color', 'transparent');
89 this.dom.classList.remove('qr--color');
90 this.dom.classList.remove('qr--borderColor');
91 }
92 }
93
94 renderSettings() {
95 if (!this.settingsDom) {
96 this.settingsDom = document.createElement('div'); {
97 this.settingsDom.classList.add('qr--set-qrListContents');
98 this.qrList.forEach((qr, idx) => {
99 this.renderSettingsItem(qr, idx);
100 });
101 }
102 }
103 return this.settingsDom;
104 }
105 /**
106 *
107 * @param {QuickReply} qr
108 * @param {number} idx
109 */
110 renderSettingsItem(qr, idx) {
111 this.settingsDom.append(qr.renderSettings(idx));
112 }
113
114 /**
115 *
116 * @param {QuickReply} qr
117 */
118 async debug(qr) {
119 const parser = new SlashCommandParser();
120 const closure = parser.parse(qr.message, true, [], qr.abortController, qr.debugController);
121 closure.source = `${this.name}.${qr.label}`;
122 closure.onProgress = (done, total) => qr.updateEditorProgress(done, total);
123 closure.scope.setMacro('arg::*', '');
124 return (await closure.execute())?.pipe;
125 }
126
127 /**
128 *
129 * @param {QuickReply} qr The QR to execute.
130 * @param {object} options
131 * @param {string} [options.message] (null) altered message to be used
132 * @param {boolean} [options.isAutoExecute] (false) whether the execution is triggered by auto execute
133 * @param {boolean} [options.isEditor] (false) whether the execution is triggered by the QR editor
134 * @param {boolean} [options.isRun] (false) whether the execution is triggered by /run or /: (window.executeQuickReplyByName)
135 * @param {SlashCommandScope} [options.scope] (null) scope to be used when running the command
136 * @param {import('../../../slash-commands.js').ExecuteSlashCommandsOptions} [options.executionOptions] ({}) further execution options
137 * @returns
138 */
139 async executeWithOptions(qr, options = {}) {
140 options = Object.assign({
141 message: null,
142 isAutoExecute: false,
143 isEditor: false,
144 isRun: false,
145 scope: null,
146 executionOptions: {},
147 }, options);
148 const execOptions = options.executionOptions;
149 /**@type {HTMLTextAreaElement}*/
150 const ta = document.querySelector('#send_textarea');
151 const finalMessage = options.message ?? qr.message;
152 let input = ta.value;
153 if (!options.isAutoExecute && !options.isEditor && !options.isRun && this.injectInput && input.length > 0) {
154 if (this.placeBeforeInput) {
155 input = `${finalMessage} ${input}`;
156 } else {
157 input = `${input} ${finalMessage}`;
158 }
159 } else {
160 input = `${finalMessage} `;
161 }
162
163 if (input[0] == '/' && !this.disableSend) {
164 let result;
165 if (options.isAutoExecute || options.isRun) {
166 result = await executeSlashCommandsWithOptions(input, Object.assign(execOptions, {
167 handleParserErrors: true,
168 scope: options.scope,
169 source: `${this.name}.${qr.label}`,
170 }));
171 } else if (options.isEditor) {
172 result = await executeSlashCommandsWithOptions(input, Object.assign(execOptions, {
173 handleParserErrors: false,
174 scope: options.scope,
175 abortController: qr.abortController,
176 source: `${this.name}.${qr.label}`,
177 onProgress: (done, total) => qr.updateEditorProgress(done, total),
178 }));
179 } else {
180 result = await executeSlashCommandsOnChatInput(input, Object.assign(execOptions, {
181 scope: options.scope,
182 source: `${this.name}.${qr.label}`,
183 }));
184 }
185 return typeof result === 'object' ? result?.pipe : '';
186 }
187
188 ta.value = substituteParams(input);
189 ta.focus();
190
191 if (!this.disableSend) {
192 // @ts-ignore
193 document.querySelector('#send_but').click();
194 }
195 }
196
197 /**
198 * @param {QuickReply} qr
199 * @param {string} [message] - optional altered message to be used
200 * @param {SlashCommandScope} [scope] - optional scope to be used when running the command
201 */
202 async execute(qr, message = null, isAutoExecute = false, scope = null) {
203 return this.executeWithOptions(qr, {
204 message,
205 isAutoExecute,
206 scope,
207 });
208 }
209
210 addQuickReply(data = {}) {
211 const id = Math.max(this.idIndex, this.qrList.reduce((max, qr) => Math.max(max, qr.id), 0)) + 1;
212 data.id = this.idIndex = id + 1;
213 const qr = QuickReply.from(data);
214 this.qrList.push(qr);
215 this.hookQuickReply(qr);
216 if (this.settingsDom) {
217 this.renderSettingsItem(qr, this.qrList.length - 1);
218 }
219 if (this.dom) {
220 this.dom.append(qr.render());
221 }
222 this.save();
223 return qr;
224 }
225
226 addQuickReplyFromText(qrJson) {
227 let data;
228 if (qrJson) {
229 try {
230 data = JSON.parse(qrJson ?? '{}');
231 delete data.id;
232 } catch {
233 // not JSON data
234 }
235 if (data) {
236 // JSON data
237 if (data.label === undefined || data.message === undefined) {
238 // not a QR
239 toastr.error('Not a QR.');
240 return;
241 }
242 } else {
243 // no JSON, use plaintext as QR message
244 data = { message: qrJson };
245 }
246 } else {
247 data = {};
248 }
249 const newQr = this.addQuickReply(data);
250 return newQr;
251 }
252
253 /**
254 *
255 * @param {QuickReply} qr
256 */
257 hookQuickReply(qr) {
258 // @ts-ignore
259 qr.onDebug = () => this.debug(qr);
260 qr.onExecute = (_, options) => this.executeWithOptions(qr, options);
261 qr.onDelete = () => this.removeQuickReply(qr);
262 qr.onUpdate = () => this.save();
263 qr.onInsertBefore = (qrJson) => {
264 this.addQuickReplyFromText(qrJson);
265 const newQr = this.qrList.pop();
266 this.qrList.splice(this.qrList.indexOf(qr), 0, newQr);
267 if (qr.settingsDom) {
268 qr.settingsDom.insertAdjacentElement('beforebegin', newQr.settingsDom);
269 }
270 this.save();
271 };
272 qr.onTransfer = async () => {
273 /**@type {HTMLSelectElement} */
274 let sel;
275 let isCopy = false;
276 const dom = document.createElement('div'); {
277 dom.classList.add('qr--transferModal');
278 const title = document.createElement('h3'); {
279 title.textContent = 'Transfer Quick Reply';
280 dom.append(title);
281 }
282 const subTitle = document.createElement('h4'); {
283 const entryName = qr.label;
284 const bookName = this.name;
285 subTitle.textContent = `${bookName}: ${entryName}`;
286 dom.append(subTitle);
287 }
288 sel = document.createElement('select'); {
289 sel.classList.add('qr--transferSelect');
290 sel.setAttribute('autofocus', '1');
291 const noOpt = document.createElement('option'); {
292 noOpt.value = '';
293 noOpt.textContent = '-- Select QR Set --';
294 sel.append(noOpt);
295 }
296 for (const qrs of QuickReplySet.list) {
297 const opt = document.createElement('option'); {
298 opt.value = qrs.name;
299 opt.textContent = qrs.name;
300 sel.append(opt);
301 }
302 }
303 sel.addEventListener('keyup', (evt) => {
304 if (evt.key == 'Shift') {
305 // @ts-ignore
306 (dlg.dom ?? dlg.dlg).classList.remove('qr--isCopy');
307 return;
308 }
309 });
310 sel.addEventListener('keydown', (evt) => {
311 if (evt.key == 'Shift') {
312 // @ts-ignore
313 (dlg.dom ?? dlg.dlg).classList.add('qr--isCopy');
314 return;
315 }
316 if (!evt.ctrlKey && !evt.altKey && evt.key == 'Enter') {
317 evt.preventDefault();
318 if (evt.shiftKey) isCopy = true;
319 dlg.completeAffirmative();
320 }
321 });
322 dom.append(sel);
323 }
324 const hintP = document.createElement('p'); {
325 const hint = document.createElement('small'); {
326 hint.textContent = 'Type or arrows to select QR Set. Enter to transfer. Shift+Enter to copy.';
327 hintP.append(hint);
328 }
329 dom.append(hintP);
330 }
331 }
332 const dlg = new Popup(dom, POPUP_TYPE.CONFIRM, null, { okButton: 'Transfer', cancelButton: 'Cancel' });
333 const copyBtn = document.createElement('div'); {
334 copyBtn.classList.add('qr--copy');
335 copyBtn.classList.add('menu_button');
336 copyBtn.textContent = 'Copy';
337 copyBtn.addEventListener('click', () => {
338 isCopy = true;
339 dlg.completeAffirmative();
340 });
341 // @ts-ignore
342 (dlg.ok ?? dlg.okButton).insertAdjacentElement('afterend', copyBtn);
343 }
344 const prom = dlg.show();
345 sel.focus();
346 await prom;
347 if (dlg.result == POPUP_RESULT.AFFIRMATIVE) {
348 const qrs = QuickReplySet.list.find(it => it.name == sel.value);
349 qrs.addQuickReply(qr.toJSON());
350 if (!isCopy) {
351 qr.delete();
352 }
353 }
354 };
355 }
356
357 removeQuickReply(qr) {
358 this.qrList.splice(this.qrList.indexOf(qr), 1);
359 this.save();
360 }
361
362 toJSON() {
363 return {
364 version: 2,
365 name: this.name,
366 disableSend: this.disableSend,
367 placeBeforeInput: this.placeBeforeInput,
368 injectInput: this.injectInput,
369 color: this.color,
370 onlyBorderColor: this.onlyBorderColor,
371 qrList: this.qrList,
372 idIndex: this.idIndex,
373 };
374 }
375
376 async performSave() {
377 const response = await fetch('/api/quick-replies/save', {
378 method: 'POST',
379 headers: getRequestHeaders(),
380 body: JSON.stringify(this),
381 });
382
383 if (response.ok) {
384 this.rerender();
385 } else {
386 warn(`Failed to save Quick Reply Set: ${this.name}`);
387 console.error('QR could not be saved', response);
388 }
389 }
390
391 async delete() {
392 const response = await fetch('/api/quick-replies/delete', {
393 method: 'POST',
394 headers: getRequestHeaders(),
395 body: JSON.stringify(this),
396 });
397
398 if (response.ok) {
399 this.unrender();
400 const idx = QuickReplySet.list.indexOf(this);
401 if (idx > -1) {
402 QuickReplySet.list.splice(idx, 1);
403 this.isDeleted = true;
404 } else {
405 warn(`Deleted Quick Reply Set was not found in the list of sets: ${this.name}`);
406 }
407 } else {
408 warn(`Failed to delete Quick Reply Set: ${this.name}`);
409 }
410 }
411}