-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathTeXSend-Gmail.user.js
439 lines (374 loc) · 20.3 KB
/
TeXSend-Gmail.user.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
// ==UserScript==
// @name TeXSend-Gmail
// @version 6.1.12
// @description Adds a button to Gmail which toggles LaTeX compiling
// @author Logan J. Fisher & GTK & MistralMireille
// @license MIT
// @namespace https://github.com/LoganJFisher/TeXSend/
// @downloadURL https://raw.githubusercontent.com/LoganJFisher/TeXSend/refs/heads/main/TeXSend-Gmail.user.js
// @updateURL https://raw.githubusercontent.com/LoganJFisher/TeXSend/refs/heads/main/TeXSend-Gmail.user.js
// @supportURL https://github.com/LoganJFisher/TeXSend/issues
// @match *://mail.google.com/mail/*
// @noframes
// @grant GM_registerMenuCommand
// @grant GM_addElement
// @grant GM_addStyle
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/contrib/mhchem.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/contrib/copy-tex.min.js
// ==/UserScript==
/* globals katex */
// ===================================================================================================
// Constants
// ===================================================================================================
const selectors = {
topBar: 'div#\\:4',
moveButton: 'div#\\:4 div[title^="Move to"], div[role=main] div[aria-label^="Move to"]',
messageList: '#\\:1 div[role=list]',
messageBody: '#\\:1 [role=list] > [role=listitem] [data-message-id] > div > div > div[id^=":"][jslog]',
draftsContainer: 'body > div.dw > div > div > div > div:first-child',
draftRegion: 'div[role=region]',
draftBody: 'div[aria-label="Message Body"]',
draftButtonContainer: 'td:has(> div > div[command=locker])',
sendButton: 'div[role=button][aria-label^=Send]',
splitViewContainer: '#\\:1 > div',
splitView: 'div[jsname=h50Ewe] > div > div > div > div',
shortcutMenu: 'body > div.wa:not(.aou) > div[role=alert]',
}
const DELIMITERS = [
{left: '[(;' , right: ';)]' , display: true, includeDelimiter: false},
{left: '\\[' , right: '\\]' , display: true, includeDelimiter: false},
{left: '[;' , right: ';]' , display: false, includeDelimiter: false},
{left: '\\(' , right: '\\)' , display: false, includeDelimiter: false},
{left: '\\begin{displaymath}' , right: '\\end{displaymath}' , display: true, includeDelimiter: false},
{left: '\\begin{math}' , right: '\\end{math}', display: false, includeDelimiter: false},
{left: '\\begin{align}' , right: '\\end{align}', display: true, includeDelimiter: true},
{left: '\\begin{align*}' , right: '\\end{align*}', display: true, includeDelimiter: true},
{left: '\\begin{aligned}' , right: '\\end{aligned}', display: true, includeDelimiter: true},
{left: '\\begin{alignat}' , right: '\\end{alignat}', display: true, includeDelimiter: true},
{left: '\\begin{alignat*}' , right: '\\end{alignat*}', display: true, includeDelimiter: true},
{left: '\\begin{alignedat}' , right: '\\end{alignedat}', display: true, includeDelimiter: true},
{left: '\\begin{array}' , right: '\\end{array}', display: true, includeDelimiter: true},
{left: '\\begin{bmatrix}' , right: '\\end{bmatrix}', display: true, includeDelimiter: true},
{left: '\\begin{bmatrix*}' , right: '\\end{bmatrix*}', display: true, includeDelimiter: true},
{left: '\\begin{Bmatrix}' , right: '\\end{Bmatrix}', display: true, includeDelimiter: true},
{left: '\\begin{Bmatrix*}' , right: '\\end{Bmatrix*}', display: true, includeDelimiter: true},
{left: '\\begin{cases}' , right: '\\end{cases}', display: true, includeDelimiter: true},
{left: '\\begin{CD}' , right: '\\end{CD}', display: true, includeDelimiter: true},
{left: '\\begin{darray}' , right: '\\end{darray}', display: true, includeDelimiter: true},
{left: '\\begin{drcases}' , right: '\\end{drcases}', display: true, includeDelimiter: true},
{left: '\\begin{equation}' , right: '\\end{equation}', display: true, includeDelimiter: true},
{left: '\\begin{equation*}' , right: '\\end{equation*}', display: true, includeDelimiter: true},
{left: '\\begin{gather}' , right: '\\end{gather}', display: true, includeDelimiter: true},
{left: '\\begin{gathered}' , right: '\\end{gathered}', display: true, includeDelimiter: true},
{left: '\\begin{matrix}' , right: '\\end{matrix}', display: true, includeDelimiter: true},
{left: '\\begin{matrix*}' , right: '\\end{matrix*}', display: true, includeDelimiter: true},
{left: '\\begin{pmatrix}' , right: '\\end{pmatrix}', display: true, includeDelimiter: true},
{left: '\\begin{pmatrix*}' , right: '\\end{pmatrix*}', display: true, includeDelimiter: true},
{left: '\\begin{rcases}' , right: '\\end{rcases}', display: true, includeDelimiter: true},
{left: '\\begin{smallmatrix}' , right: '\\end{smallmatrix}', display: false, includeDelimiter: true}, //Take note that display is false on this one
{left: '\\begin{split}' , right: '\\end{split}', display: true, includeDelimiter: true},
{left: '\\begin{subarray}' , right: '\\end{subarray}', display: true, includeDelimiter: true},
{left: '\\begin{Vmatrix}' , right: '\\end{Vmatrix}', display: true, includeDelimiter: true},
{left: '\\begin{Vmatrix*}' , right: '\\end{Vmatrix*}', display: true, includeDelimiter: true},
{left: '\\begin{vmatrix}' , right: '\\end{vmatrix}', display: true, includeDelimiter: true},
{left: '\\begin{vmatrix*}' , right: '\\end{vmatrix*}', display: true, includeDelimiter: true},
]
// ===================================================================================================
// Latex
// ===================================================================================================
const REGEX = buildRegex(DELIMITERS);
/*
Build one BIG regex from all the delimiters. (using disjunction `|` aka OR)
`tex` & `d` are named capture groups
depending on `includeDelimiter` tex will either include the delimiters or only what's between them: tex = (left...right) or left(...)right
depending on `display` an empty capture group `d` is added at the end to indicate display(displayMode) for that particular delimiter pair.
*/
function buildRegex(delims) {
const escape = string => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // escapes the special characters in the delimiters.
const expressions = delims.map( d => {
const display = d.display ? '(?<d>)' : '';
const exp = d.includeDelimiter ? `(?<tex>${escape(d.left)}.+?${escape(d.right)})${display}` : `${escape(d.left)}(?<tex>.+?)${escape(d.right)}${display}`;
return exp;
})
return new RegExp(expressions.join('|'), 'gs');
}
function renderLatex(html) {
html = html.replace(/<wbr>/gs, ''); // fixes parsing of long expressions (GMAIL inserts <wbr> tags for some reason)
const div = document.createElement('div');
html = html.replace(REGEX, function() {
const groups = arguments[arguments.length - 1]; // get last argument (named groups)
const display = groups.d !== undefined; // diplay will be true if the match includes `d` (the empty group)
div.innerHTML = groups.tex.replace(/ /gs, '').trim();
return katex.renderToString(div.textContent, {throwOnError: false, displayMode: display, trust: true, strict: false})
})
return html;
}
/*
takes a list of html elements and swaps their innerHTML between the stored `oldHTML` and rendered LaTeX depending on `state`
this is meant to work for both messages & drafts, so NO caching of the rendered LaTeX is done.
*/
function updateLatex(messageList, state) {
messageList.forEach(message => {
if (state === message.rendered) return;
if (state && !message.rendered) {
message.oldHTML = message.innerHTML;
message.innerHTML = renderLatex(message.innerHTML);
message.rendered = true;
} else {
message.oldHTML && (message.innerHTML = message.oldHTML);
message.rendered = false;
}
});
}
// ===================================================================================================
// MESSAGES
// ===================================================================================================
let MESSAGES_TOGGLE = true;
/*
Adds the toggle button next to the `Move to` button
copy the classes/structure of native buttons to avoid the many styling issues (compatibility with different themes & darkreader)
*/
function addMessageToggleButton() {
const moveBtn = document.querySelector(selectors.moveButton);
if (!moveBtn || moveBtn.parentElement.processed) return;
const parent = moveBtn.parentElement;
const latexButton = GM_addElement(parent, 'div', {
id: 'latex_toggle_message_button',
role: 'button',
'data-tooltip': 'Toggle LaTeX',
});
const logoDiv = GM_addElement(latexButton, 'div', {
class: 'asa',
style: 'width: 20px; height: 20px; display: inline-flex; align-items: end',
});
logoDiv.innerHTML = katex.renderToString('\\footnotesize \\TeX', {throwOnError: false});
latexButton.addEventListener('click', toggleMessages);
latexButton.addEventListener('mouseover', () => latexButton.classList.add('T-I-JW'));
latexButton.addEventListener('mouseout', () => latexButton.classList.remove('T-I-JW'));
parent.processed = true;
}
/*
calls refreshMessages() & processDrafts() everytime a message is expanded/reply is added
`itemObserver` watches changes in the attributes `aria-expanded` (when a message is expanded) & class (changes when a reply draft to that message is opened)
`listObserver` watches new replies/messages in the chain and applies `itemObserver` to them.
*/
function observeMessages() {
const messageList = document.querySelector(selectors.messageList);
if (!messageList) return;
const itemObserver = new MutationObserver( () => {
refreshMessages();
processDrafts(messageList);
});
function _observe() {
const messages = messageList.querySelectorAll('div[role=listitem]');
messages.forEach( msg => itemObserver.observe(msg, {attributes: true, attributeFilter: ["aria-expanded", "class"]}) );
}
const listObserver = new MutationObserver(_observe);
listObserver.observe(messageList, {childList: true});
_observe();
}
// simply calls updateLatex with the message list
function refreshMessages() {
const list = document.querySelectorAll(selectors.messageBody);
updateLatex(list, MESSAGES_TOGGLE);
}
// toggles/switches the `MESSAGES_TOGGLE` and refreshes the messages
function toggleMessages() {
MESSAGES_TOGGLE = !MESSAGES_TOGGLE;
refreshMessages();
}
// ===================================================================================================
// DRAFTS
// ===================================================================================================
// Goes through all the drafts (compose or reply) and adds the TeX button and the banner to each.
function processDrafts(container) {
const drafts = container.querySelectorAll(selectors.draftRegion);
drafts.forEach( draft => {
if (draft.processed) return; // make sure we don't process the same draft multiple times
addDraftToggleButton(draft);
addBanner(draft);
attachSendListener(draft);
draft.processed = true;
})
}
// simply set the given draft to the given state (toggle if state=null)
function toggleDraft(draft, state=null) {
const draftBody = draft.querySelector(selectors.draftBody);
state = state !== null ? state : !draftBody.rendered;
updateLatex([draftBody], state)
draftBody.setAttribute('contenteditable', !state); // disable/enable editing based on latex toggle
draft.bannerDiv.style.display = state ? '': 'none'; // hide/show the banner
}
function addDraftToggleButton(draft) {
const buttonContainer = draft.querySelector(selectors.draftButtonContainer);
if (!buttonContainer) return;
const button = GM_addElement(buttonContainer, 'div', {
id: 'latex_toggle_draft_button',
class: 'J-Z-I',
role: 'button',
'data-tooltip': 'Toggle LaTeX',
});
button.innerHTML = katex.renderToString('\\footnotesize \\TeX', {throwOnError: false});
button.addEventListener('click', () => toggleDraft(draft));
draft.addEventListener('keydown', draftShortcutHandler, true);
}
function addBanner(draft) {
const parent = draft.querySelector('td:has(> form)');
const bannerDiv = GM_addElement(document.body, 'div', {
textContent: 'Disable LaTeX to edit draft',
id: 'latex_draft_banner',
style: 'display: none;',
});
parent.insertBefore(bannerDiv, parent.children[parent.children.length - 1]); // insert the banner under `Subject`
draft.bannerDiv = bannerDiv;
}
// makes sure we send the raw draft (not rendered latex) when the send button is clicked
function attachSendListener(draft) {
const sendButton = draft.querySelector(selectors.sendButton);
sendButton.addEventListener('click', () => toggleDraft(draft, false), true);
}
// adds shortcuts within the draft
// keyCodes 75 = K, 76 = L, 13 = Enter
function draftShortcutHandler(event) {
if (event.ctrlKey && event.altKey && event.keyCode === 75) {
toggleDraft(event.currentTarget);
} else if (event.ctrlKey && event.altKey && event.keyCode === 76) { //this listener is only for compose drafts since they don't bubble events
event.stopPropagation(); // reply drafts bubble the events, this avoids a double call to `toggleMessages`
toggleMessages();
} else if (event.ctrlKey && event.keyCode === 13) { // ctrl + enter is the draft send shortcut
toggleDraft(event.currentTarget, false);
}
}
// ===================================================================================================
// UTILS
// ===================================================================================================
function waitForElement(queryString, interval=100, maxTries=100) {
let count = 0;
function findElement(resolve, reject) {
count += 1;
let waitElement = document.querySelector(queryString);
if(waitElement) {
resolve(waitElement);
} else if(count > maxTries) {
reject(`Couldn't find waitElement: ${queryString}.`);
} else {
setTimeout(() => findElement(resolve, reject), interval);
}
}
return new Promise(findElement);
}
/*
gets the 'empty' row under formatting and replaces it with 2 rows (toggle messages & toggle drafts)
Used xpath because there isn't a reliable css selector for the `Formatting` section of the shortcuts table/menu
Used waitForElement because we need to insert the new shortcuts everytime the menu is opened (the table is restored everytime it is opened for some reason)
*/
function addShortcuts() {
const xpath = '//tr[th/text()="Formatting"]/following-sibling::tr';
const msg_ShortcutHTML = '<tr><td class="wg Dn"><span class="wh">Ctrl</span> <span class="wb">+</span> <span class="wh">Alt</span> <span class="wb">+</span> <span class="wh">L</span> :</td><td class="we Dn">Toggle LaTeX (inbox)</td></tr>';
const draft_ShortcutHTML = '<tr><td class="wg Dn"><span class="wh">Ctrl</span> <span class="wb">+</span> <span class="wh">Alt</span> <span class="wb">+</span> <span class="wh">K</span> :</td><td class="we Dn">Toggle LaTeX (draft)</td></tr>';
document.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.altKey && event.keyCode === 76) { //'L'
toggleMessages();
} else if (event.shiftKey && event.keyCode === 191) { //'?'
waitForElement(selectors.shortcutMenu, 5).then(d => {
const row = document.evaluate(xpath, d, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
row.outerHTML = msg_ShortcutHTML + draft_ShortcutHTML;
});
}
});
}
function addStyles() {
GM_addElement('link', {
rel: "stylesheet",
href: "https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.css"
});
GM_addStyle(`
.katex-display {
max-width: 99%;
}
#\\:1 [role=list] > [role=listitem] {
counter-reset: katexEqnNo;
}
#latex_toggle_message_button {
user-select: none;
margin: 0 16px 0 12px;
color: var(--darkreader-text--gm3-sys-color-on-surface, var(--gm3-sys-color-on-surface));
}
#latex_toggle_draft_button {
user-select: none;
width: 20px;
height: 20px;
margin: 4px 16px 4px -4px;
color: var(--darkreader-text--gm3-sys-color-on-surface, var(--gm3-sys-color-on-surface));
}
#latex_toggle_draft_button > .katex .base {
display: flex;
}
#latex_draft_banner {
user-select: none;
background-color: rgb(255, 85, 85);
color: white;
display: flex;
align-items: center;
justify-content: center;
padding: 5px 50px;
position: sticky;
bottom: 0;
}
`);
}
// ===================================================================================================
// MAIN
// ===================================================================================================
function init() {
const config = {attributes: false, childList: true};
const observer = new MutationObserver( (mutations) => {
if (!mutations.some(m => m.addedNodes.length)) return; // proceed only if new messages have been added.
addMessageToggleButton();
refreshMessages();
observeMessages();
});
// try to find the split view (if not found it's probably disabled)
function observeSplitView() {
const splitViews = document.querySelectorAll(selectors.splitView);
splitViews.forEach( view => observer.observe(view, config));
}
// the topbar is an element that will mutate everytime a new email is opened (when not in split mode)
waitForElement(selectors.topBar).then( topbar => observer.observe(topbar, config));
waitForElement(selectors.splitView).then(observeSplitView);
// observe the body until the compose container appears (all compose drafts are children of this container)
// once it does we can observe it specifically for new compose drafts
const bodyObserver = new MutationObserver( () => {
const draftsContainer = document.querySelector(selectors.draftsContainer);
if (!draftsContainer) return;
bodyObserver.disconnect();
const _callback = () => processDrafts(draftsContainer);
const draftsObserver = new MutationObserver(_callback);
draftsObserver.observe(draftsContainer, {childList: true});
_callback();
});
bodyObserver.observe(document.body, {attributes: true});
// some labels have their own splitView divs, this makes sure we observe any new ones
waitForElement(selectors.splitViewContainer).then( div => {
new MutationObserver(observeSplitView).observe(div, {childList: true});
});
}
function main() {
// allows modifying the html on gmail
if (window.trustedTypes && window.trustedTypes.createPolicy && !window.trustedTypes.defaultPolicy) {
window.trustedTypes.createPolicy('default', {
createHTML: string => string
});
}
addStyles();
addShortcuts();
GM_registerMenuCommand('Toggle LaTeX', toggleMessages);
init();
}
main();
// Legal:
// This userscript, TeXSend-Gmail, is an independent project and is not affiliated with, endorsed, sponsored, or supported by Google LLC, Alphabet Inc., or any of their subsidiaries. The aforementioned entities are not responsible for any issues, damages, or consequences arising from the use of this userscript.
// By using this userscript, you acknowledge that you are doing so at your own risk and agree to hold Google LLC, Alphabet Inc., and their respective affiliates harmless from any claims, losses, or damages arising from your use of this userscript.
// Google LLC reserves the right to request that the distribution of this userscript be ceased, or that the userscript be altered, if it violates Google's terms of service, policies, or guidelines, or if it causes harm to Google's reputation, user experience, or data privacy.
// The MIT license under which this userscript is distributed can be viewed [here](https://github.com/LoganJFisher/TeXSend?tab=MIT-1-ov-file).