@kerebron/extension-codejar 0.4.28 → 0.4.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/CodeJar.ts ADDED
@@ -0,0 +1,636 @@
1
+ import * as dntShim from "./_dnt.shims.js";
2
+ const globalWindow = dntShim.dntGlobalThis;
3
+
4
+ type Options = {
5
+ tab: string;
6
+ indentOn: RegExp;
7
+ moveToNewLine: RegExp;
8
+ spellcheck: boolean;
9
+ catchTab: boolean;
10
+ preserveIdent: boolean;
11
+ addClosing: boolean;
12
+ history: boolean;
13
+ readOnly?: boolean;
14
+ window: typeof dntShim.dntGlobalThis;
15
+ autoclose: {
16
+ open: string;
17
+ close: string;
18
+ };
19
+ };
20
+
21
+ type HistoryRecord = {
22
+ html: string;
23
+ pos: Position;
24
+ };
25
+
26
+ export type Position = {
27
+ start: number;
28
+ end: number;
29
+ dir?: '->' | '<-';
30
+ };
31
+
32
+ function visit(
33
+ editor: HTMLElement,
34
+ visitor: (el: Node) => 'stop' | undefined,
35
+ ) {
36
+ const queue: Node[] = [];
37
+ if (editor.firstChild) queue.push(editor.firstChild);
38
+ let el = queue.pop();
39
+ while (el) {
40
+ if (visitor(el) === 'stop') break;
41
+ if (el.nextSibling) queue.push(el.nextSibling);
42
+ if (el.firstChild) queue.push(el.firstChild);
43
+ el = queue.pop();
44
+ }
45
+ }
46
+
47
+ function isCtrl(event: KeyboardEvent) {
48
+ return event.metaKey || event.ctrlKey;
49
+ }
50
+
51
+ function isUndo(event: KeyboardEvent) {
52
+ return isCtrl(event) && !event.shiftKey && getKeyCode(event) === 'Z';
53
+ }
54
+
55
+ function isRedo(event: KeyboardEvent) {
56
+ return isCtrl(event) && event.shiftKey && getKeyCode(event) === 'Z';
57
+ }
58
+
59
+ function isCopy(event: KeyboardEvent) {
60
+ return isCtrl(event) && getKeyCode(event) === 'C';
61
+ }
62
+
63
+ function getKeyCode(event: KeyboardEvent): string | undefined {
64
+ let key = event.key || event.keyCode || event.which;
65
+ if (!key) return undefined;
66
+ return (typeof key === 'string' ? key : String.fromCharCode(key))
67
+ .toUpperCase();
68
+ }
69
+
70
+ function insert(text: string) {
71
+ text = text
72
+ .replace(/&/g, '&amp;')
73
+ .replace(/</g, '&lt;')
74
+ .replace(/>/g, '&gt;')
75
+ .replace(/"/g, '&quot;')
76
+ .replace(/'/g, '&#039;');
77
+ document.execCommand('insertHTML', false, text);
78
+ }
79
+
80
+ function debounce(cb: any, wait: number) {
81
+ let timeout = 0;
82
+ return (...args: any) => {
83
+ clearTimeout(timeout);
84
+ timeout = globalThis.setTimeout(() => cb(...args), wait);
85
+ };
86
+ }
87
+
88
+ function findPadding(text: string): [string, number, number] {
89
+ // Find beginning of previous line.
90
+ let i = text.length - 1;
91
+ while (i >= 0 && text[i] !== '\n') i--;
92
+ i++;
93
+ // Find padding of the line.
94
+ let j = i;
95
+ while (j < text.length && /[ \t]/.test(text[j])) j++;
96
+ return [text.substring(i, j) || '', i, j];
97
+ }
98
+
99
+ export class CodeJar extends EventTarget {
100
+ options: Options;
101
+ listeners: [string, any][] = [];
102
+ history: HistoryRecord[] = [];
103
+ at = -1;
104
+ onUpdateCbk: (code: string) => void | undefined = () => void 0;
105
+ focus = false;
106
+ prev: string | undefined; // code content prior keydown event
107
+
108
+ constructor(
109
+ private editor: HTMLElement,
110
+ private highlight: (e: HTMLElement, pos?: Position) => void,
111
+ opt: Partial<Options> = {},
112
+ ) {
113
+ super();
114
+
115
+ this.options = {
116
+ tab: '\t',
117
+ indentOn: /[({\[]$/,
118
+ moveToNewLine: /^[)}\]]/,
119
+ spellcheck: false,
120
+ catchTab: true,
121
+ preserveIdent: true,
122
+ addClosing: true,
123
+ history: true,
124
+ window: globalWindow,
125
+ autoclose: {
126
+ open: '',
127
+ close: '',
128
+ },
129
+ ...opt,
130
+ };
131
+
132
+ const window = this.options.window;
133
+ const document = window.document;
134
+
135
+ editor.style.outline = 'none';
136
+ editor.style.overflowWrap = 'break-word';
137
+ editor.style.overflowY = 'auto';
138
+ editor.style.whiteSpace = 'pre-wrap';
139
+
140
+ const matchFirefoxVersion = window.navigator.userAgent.match(
141
+ /Firefox\/([0-9]+)\./,
142
+ );
143
+ const firefoxVersion = matchFirefoxVersion
144
+ ? parseInt(matchFirefoxVersion[1])
145
+ : 0;
146
+
147
+ let isLegacy = false; // true if plaintext-only is not supported
148
+ if (
149
+ editor.contentEditable !== 'plaintext-only' || firefoxVersion >= 136
150
+ ) {
151
+ isLegacy = true;
152
+ }
153
+ if (isLegacy) editor.setAttribute('contenteditable', 'true');
154
+
155
+ if (!opt.readOnly) {
156
+ editor.setAttribute('contenteditable', 'plaintext-only');
157
+ editor.setAttribute(
158
+ 'spellcheck',
159
+ this.options.spellcheck ? 'true' : 'false',
160
+ );
161
+ }
162
+
163
+ const debounceHighlight = debounce(() => {
164
+ const pos = this.save();
165
+ this.doHighlight(editor, pos);
166
+ this.restore(pos);
167
+ }, 30);
168
+
169
+ let recording = false;
170
+ const shouldRecord = (event: KeyboardEvent): boolean => {
171
+ return !isUndo(event) && !isRedo(event) &&
172
+ event.key !== 'Meta' &&
173
+ event.key !== 'Control' &&
174
+ event.key !== 'Alt' &&
175
+ !event.key.startsWith('Arrow');
176
+ };
177
+ const debounceRecordHistory = debounce((event: KeyboardEvent) => {
178
+ if (shouldRecord(event)) {
179
+ this.recordHistory();
180
+ recording = false;
181
+ }
182
+ }, 300);
183
+
184
+ const on = <K extends keyof HTMLElementEventMap>(
185
+ type: K,
186
+ fn: (event: HTMLElementEventMap[K]) => void,
187
+ ) => {
188
+ this.listeners.push([type, fn]);
189
+ this.editor.addEventListener(type, fn);
190
+ };
191
+
192
+ on('keydown', (event) => {
193
+ if (event.defaultPrevented) return;
194
+
195
+ this.prev = this.toString();
196
+ if (this.options.preserveIdent) handleNewLine(event);
197
+ else legacyNewLineFix(event);
198
+ if (this.options.catchTab) handleTabCharacters(event);
199
+ if (this.options.addClosing) handleSelfClosingCharacters(event);
200
+ if (this.options.history) {
201
+ handleUndoRedo(event);
202
+ if (shouldRecord(event) && !recording) {
203
+ this.recordHistory();
204
+ recording = true;
205
+ }
206
+ }
207
+ if (isLegacy && !isCopy(event)) this.restore(this.save());
208
+ });
209
+
210
+ on('keyup', (event) => {
211
+ if (event.defaultPrevented) return;
212
+ if (event.isComposing) return;
213
+
214
+ if (this.prev !== this.toString()) debounceHighlight();
215
+ debounceRecordHistory(event);
216
+ this.onUpdateCbk(this.toString());
217
+ });
218
+
219
+ on('focus', (_event) => {
220
+ this.focus = true;
221
+ });
222
+
223
+ on('blur', (_event) => {
224
+ this.focus = false;
225
+ });
226
+
227
+ on('paste', (event) => {
228
+ this.recordHistory();
229
+ handlePaste(event);
230
+ this.recordHistory();
231
+ this.onUpdateCbk(this.toString());
232
+ });
233
+
234
+ on('cut', (event) => {
235
+ this.recordHistory();
236
+ handleCut(event);
237
+ this.recordHistory();
238
+ this.onUpdateCbk(this.toString());
239
+ });
240
+
241
+ const beforeCursor = () => {
242
+ const s = this.getSelection();
243
+ const r0 = s.getRangeAt(0);
244
+ const r = document.createRange();
245
+ r.selectNodeContents(editor);
246
+ r.setEnd(r0.startContainer, r0.startOffset);
247
+ return r.toString();
248
+ };
249
+
250
+ const afterCursor = () => {
251
+ const s = this.getSelection();
252
+ const r0 = s.getRangeAt(0);
253
+ const r = document.createRange();
254
+ r.selectNodeContents(editor);
255
+ r.setStart(r0.endContainer, r0.endOffset);
256
+ return r.toString();
257
+ };
258
+
259
+ const handleNewLine = (event: KeyboardEvent) => {
260
+ if (event.key === 'Enter') {
261
+ const before = beforeCursor();
262
+ const after = afterCursor();
263
+
264
+ let [padding] = findPadding(before);
265
+ let newLinePadding = padding;
266
+
267
+ // If last symbol is "{" ident new line
268
+ if (this.options.indentOn.test(before)) {
269
+ newLinePadding += this.options.tab;
270
+ }
271
+
272
+ // Preserve padding
273
+ if (newLinePadding.length > 0) {
274
+ event.preventDefault();
275
+ event.stopPropagation();
276
+ insert('\n' + newLinePadding);
277
+ } else {
278
+ legacyNewLineFix(event);
279
+ }
280
+
281
+ // Place adjacent "}" on next line
282
+ if (
283
+ newLinePadding !== padding && this.options.moveToNewLine.test(after)
284
+ ) {
285
+ const pos = this.save();
286
+ insert('\n' + padding);
287
+ this.restore(pos);
288
+ }
289
+ }
290
+ };
291
+
292
+ const legacyNewLineFix = (event: KeyboardEvent) => {
293
+ // Firefox does not support plaintext-only mode
294
+ // and puts <div><br></div> on Enter. Let's help.
295
+ if (isLegacy && event.key === 'Enter') {
296
+ event.preventDefault();
297
+ event.stopPropagation();
298
+ if (afterCursor() == '') {
299
+ insert('\n ');
300
+ const pos = this.save();
301
+ pos.start = --pos.end;
302
+ this.restore(pos);
303
+ } else {
304
+ insert('\n');
305
+ }
306
+ }
307
+ };
308
+
309
+ const handleSelfClosingCharacters = (event: KeyboardEvent) => {
310
+ const open = this.options.autoclose.open;
311
+ const close = this.options.autoclose.close;
312
+ if (open.includes(event.key)) {
313
+ event.preventDefault();
314
+ const pos = this.save();
315
+ const wrapText = pos.start == pos.end
316
+ ? ''
317
+ : this.getSelection().toString();
318
+ const text = event.key + wrapText +
319
+ (close[open.indexOf(event.key)] ?? '');
320
+ insert(text);
321
+ pos.start++;
322
+ pos.end++;
323
+ this.restore(pos);
324
+ }
325
+ };
326
+
327
+ const handleTabCharacters = (event: KeyboardEvent) => {
328
+ if (event.key === 'Tab') {
329
+ event.preventDefault();
330
+ if (event.shiftKey) {
331
+ const before = beforeCursor();
332
+ let [padding, start] = findPadding(before);
333
+ if (padding.length > 0) {
334
+ const pos = this.save();
335
+ // Remove full length tab or just remaining padding
336
+ const len = Math.min(this.options.tab.length, padding.length);
337
+ this.restore({ start, end: start + len });
338
+ document.execCommand('delete');
339
+ pos.start -= len;
340
+ pos.end -= len;
341
+ this.restore(pos);
342
+ }
343
+ } else {
344
+ insert(this.options.tab);
345
+ }
346
+ }
347
+ };
348
+
349
+ const handleUndoRedo = (event: KeyboardEvent) => {
350
+ if (isUndo(event)) {
351
+ event.preventDefault();
352
+ this.at--;
353
+ const record = this.history[this.at];
354
+ if (record) {
355
+ editor.innerHTML = record.html;
356
+ this.restore(record.pos);
357
+ }
358
+ if (this.at < 0) this.at = 0;
359
+ }
360
+ if (isRedo(event)) {
361
+ event.preventDefault();
362
+ this.at++;
363
+ const record = this.history[this.at];
364
+ if (record) {
365
+ editor.innerHTML = record.html;
366
+ this.restore(record.pos);
367
+ }
368
+ if (this.at >= history.length) this.at--;
369
+ }
370
+ };
371
+
372
+ const handlePaste = (event: ClipboardEvent) => {
373
+ if (event.defaultPrevented) return;
374
+ event.preventDefault();
375
+ const originalEvent = (event as any).originalEvent ?? event;
376
+ const text = originalEvent.clipboardData.getData('text/plain').replace(
377
+ /\r\n?/g,
378
+ '\n',
379
+ );
380
+ const pos = this.save();
381
+ insert(text);
382
+ this.doHighlight(editor);
383
+ this.restore({
384
+ start: Math.min(pos.start, pos.end) + text.length,
385
+ end: Math.min(pos.start, pos.end) + text.length,
386
+ dir: '<-',
387
+ });
388
+ };
389
+
390
+ const handleCut = (event: ClipboardEvent) => {
391
+ const pos = this.save();
392
+ const selection = this.getSelection();
393
+ const originalEvent = (event as any).originalEvent ?? event;
394
+ originalEvent.clipboardData.setData('text/plain', selection.toString());
395
+ document.execCommand('delete');
396
+ this.doHighlight(editor);
397
+ this.restore({
398
+ start: Math.min(pos.start, pos.end),
399
+ end: Math.min(pos.start, pos.end),
400
+ dir: '<-',
401
+ });
402
+ event.preventDefault();
403
+ };
404
+ }
405
+
406
+ getSelection() {
407
+ return this.editor.getRootNode().getSelection() as Selection;
408
+ }
409
+
410
+ private doHighlight(editor: HTMLElement, pos?: Position) {
411
+ this.highlight(editor, pos);
412
+ }
413
+
414
+ private uneditable(node: Node): Element | undefined {
415
+ while (node && node !== this.editor) {
416
+ if (node.nodeType === Node.ELEMENT_NODE) {
417
+ const el = node as Element;
418
+ if (el.getAttribute('contenteditable') == 'false') {
419
+ return el;
420
+ }
421
+ }
422
+ node = node.parentNode!;
423
+ }
424
+ }
425
+
426
+ override toString() {
427
+ return this.editor.textContent || '';
428
+ }
429
+
430
+ updateOptions(newOptions: Partial<Options>) {
431
+ Object.assign(this.options, newOptions);
432
+ }
433
+
434
+ updateCode(code: string, callOnUpdate: boolean = true) {
435
+ this.editor.textContent = code;
436
+ this.doHighlight(this.editor);
437
+ callOnUpdate && this.onUpdateCbk(code);
438
+ }
439
+
440
+ onUpdate(callback: (code: string) => void) {
441
+ this.onUpdateCbk = callback;
442
+ }
443
+
444
+ save(): Position | undefined {
445
+ const s = this.getSelection();
446
+
447
+ const pos: Position = { start: 0, end: 0, dir: undefined };
448
+ if (!s) {
449
+ return pos;
450
+ }
451
+
452
+ let { anchorNode, anchorOffset, focusNode, focusOffset } = s;
453
+ if (!anchorNode) {
454
+ console.warn('No anchorNode');
455
+ return undefined;
456
+ }
457
+ if (!focusNode) {
458
+ console.warn('No focusNode');
459
+ return undefined;
460
+ }
461
+
462
+ // If the anchor and focus are the editor element, return either a full
463
+ // highlight or a start/end cursor position depending on the selection
464
+ if (anchorNode === this.editor && focusNode === this.editor) {
465
+ pos.start = (anchorOffset > 0 && this.editor.textContent)
466
+ ? this.editor.textContent.length
467
+ : 0;
468
+ pos.end = (focusOffset > 0 && this.editor.textContent)
469
+ ? this.editor.textContent.length
470
+ : 0;
471
+ pos.dir = (focusOffset >= anchorOffset) ? '->' : '<-';
472
+ return pos;
473
+ }
474
+
475
+ // Selection anchor and focus are expected to be text nodes,
476
+ // so normalize them.
477
+ if (anchorNode.nodeType === Node.ELEMENT_NODE) {
478
+ const node = document.createTextNode('');
479
+ anchorNode.insertBefore(node, anchorNode.childNodes[anchorOffset]);
480
+ anchorNode = node;
481
+ anchorOffset = 0;
482
+ }
483
+ if (focusNode.nodeType === Node.ELEMENT_NODE) {
484
+ const node = document.createTextNode('');
485
+ focusNode.insertBefore(node, focusNode.childNodes[focusOffset]);
486
+ focusNode = node;
487
+ focusOffset = 0;
488
+ }
489
+
490
+ visit(this.editor, (el) => {
491
+ if (el === anchorNode && el === focusNode) {
492
+ pos.start += anchorOffset;
493
+ pos.end += focusOffset;
494
+ pos.dir = anchorOffset <= focusOffset ? '->' : '<-';
495
+ return 'stop';
496
+ }
497
+
498
+ if (el === anchorNode) {
499
+ pos.start += anchorOffset;
500
+ if (!pos.dir) {
501
+ pos.dir = '->';
502
+ } else {
503
+ return 'stop';
504
+ }
505
+ } else if (el === focusNode) {
506
+ pos.end += focusOffset;
507
+ if (!pos.dir) {
508
+ pos.dir = '<-';
509
+ } else {
510
+ return 'stop';
511
+ }
512
+ }
513
+
514
+ if (el.nodeType === Node.TEXT_NODE) {
515
+ if (pos.dir != '->') pos.start += el.nodeValue!.length;
516
+ if (pos.dir != '<-') pos.end += el.nodeValue!.length;
517
+ }
518
+ });
519
+
520
+ this.editor.normalize(); // collapse empty text nodes
521
+ return pos;
522
+ }
523
+
524
+ restore(pos: Position) {
525
+ const s = this.getSelection();
526
+ if (!s) {
527
+ return;
528
+ }
529
+
530
+ let startNode: Node | undefined, startOffset = 0;
531
+ let endNode: Node | undefined, endOffset = 0;
532
+
533
+ if (!pos.dir) pos.dir = '->';
534
+ if (pos.start < 0) pos.start = 0;
535
+ if (pos.end < 0) pos.end = 0;
536
+
537
+ // Flip start and end if the direction reversed
538
+ if (pos.dir == '<-') {
539
+ const { start, end } = pos;
540
+ pos.start = end;
541
+ pos.end = start;
542
+ }
543
+
544
+ let current = 0;
545
+
546
+ visit(this.editor, (el) => {
547
+ if (el.nodeType !== Node.TEXT_NODE) return;
548
+
549
+ const len = (el.nodeValue || '').length;
550
+ if (current + len > pos.start) {
551
+ if (!startNode) {
552
+ startNode = el;
553
+ startOffset = pos.start - current;
554
+ }
555
+ if (current + len > pos.end) {
556
+ endNode = el;
557
+ endOffset = pos.end - current;
558
+ return 'stop';
559
+ }
560
+ }
561
+ current += len;
562
+ });
563
+
564
+ if (!startNode) {
565
+ startNode = this.editor;
566
+ startOffset = this.editor.childNodes.length;
567
+ }
568
+ if (!endNode) {
569
+ endNode = this.editor;
570
+ endOffset = this.editor.childNodes.length;
571
+ }
572
+
573
+ // Flip back the selection
574
+ if (pos.dir == '<-') {
575
+ [startNode, startOffset, endNode, endOffset] = [
576
+ endNode,
577
+ endOffset,
578
+ startNode,
579
+ startOffset,
580
+ ];
581
+ }
582
+
583
+ {
584
+ // If nodes not editable, create a text node.
585
+ const startEl = this.uneditable(startNode);
586
+ if (startEl) {
587
+ const node = document.createTextNode('');
588
+ startEl.parentNode?.insertBefore(node, startEl);
589
+ startNode = node;
590
+ startOffset = 0;
591
+ }
592
+ const endEl = this.uneditable(endNode);
593
+ if (endEl) {
594
+ const node = document.createTextNode('');
595
+ endEl.parentNode?.insertBefore(node, endEl);
596
+ endNode = node;
597
+ endOffset = 0;
598
+ }
599
+ }
600
+
601
+ s.setBaseAndExtent(startNode, startOffset, endNode, endOffset);
602
+ this.editor.normalize(); // collapse empty text nodes
603
+ }
604
+
605
+ recordHistory() {
606
+ if (!focus) return;
607
+
608
+ const html = this.editor.innerHTML;
609
+ const pos = this.save();
610
+
611
+ const lastRecord = this.history[this.at];
612
+ if (lastRecord) {
613
+ if (
614
+ lastRecord.html === html &&
615
+ lastRecord.pos.start === pos.start &&
616
+ lastRecord.pos.end === pos.end
617
+ ) return;
618
+ }
619
+
620
+ this.at++;
621
+ this.history[this.at] = { html, pos };
622
+ this.history.splice(this.at + 1);
623
+
624
+ const maxHistory = 300;
625
+ if (this.at > maxHistory) {
626
+ this.at = maxHistory;
627
+ this.history.splice(0, 1);
628
+ }
629
+ }
630
+
631
+ destroy() {
632
+ for (let [type, fn] of this.listeners) {
633
+ this.editor.removeEventListener(type, fn);
634
+ }
635
+ }
636
+ }
@@ -0,0 +1,67 @@
1
+ export interface DecorationInline {
2
+ startIndex: number;
3
+ endIndex: number;
4
+ className: string;
5
+ title?: string;
6
+ }
7
+
8
+ export class Decorator {
9
+ public decorationGroups: Record<string, DecorationInline[]> = {};
10
+
11
+ highlight(code: string) {
12
+ const decorations: DecorationInline[] = [];
13
+
14
+ for (const groupName in this.decorationGroups) {
15
+ const group = this.decorationGroups[groupName];
16
+ decorations.push(...group);
17
+ }
18
+
19
+ const cutIndexes = new Set<number>();
20
+ for (const d of decorations) {
21
+ cutIndexes.add(d.startIndex);
22
+ cutIndexes.add(d.endIndex);
23
+ }
24
+ cutIndexes.add(0);
25
+ cutIndexes.add(code.length);
26
+
27
+ const cutIndexesArr = Array.from(cutIndexes);
28
+ cutIndexesArr.sort((a, b) => a - b);
29
+
30
+ let html = '';
31
+ let lastIndex = 0;
32
+
33
+ for (const currentIdx of cutIndexesArr) {
34
+ const text = code.substring(lastIndex, currentIdx);
35
+ const activeDecors = decorations.filter((d) =>
36
+ lastIndex >= d.startIndex && currentIdx <= d.endIndex
37
+ );
38
+
39
+ for (const decor of activeDecors) {
40
+ if (decor.title) {
41
+ html += `<span class="${decor.className}" title="${
42
+ escapeHtml(decor.title || '')
43
+ }">`;
44
+ } else {
45
+ html += `<span class="${decor.className}">`;
46
+ }
47
+ }
48
+
49
+ html += escapeHtml(text);
50
+
51
+ for (const decor of activeDecors) {
52
+ html += '</span>';
53
+ }
54
+
55
+ lastIndex = currentIdx;
56
+ }
57
+
58
+ return html;
59
+ }
60
+ }
61
+
62
+ function escapeHtml(text: string) {
63
+ return text
64
+ .replace(/&/g, '&amp;')
65
+ .replace(/</g, '&lt;')
66
+ .replace(/>/g, '&gt;');
67
+ }