lexgui 8.2.5 → 8.3.0

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.
Files changed (35) hide show
  1. package/build/components/NodeTree.d.ts +51 -51
  2. package/build/components/Tabs.d.ts +1 -0
  3. package/build/core/Namespace.js +1 -1
  4. package/build/core/Namespace.js.map +1 -1
  5. package/build/extensions/AssetView.d.ts +138 -138
  6. package/build/extensions/AssetView.js +1433 -1433
  7. package/build/extensions/CodeEditor.d.ts +466 -363
  8. package/build/extensions/CodeEditor.js +3768 -4638
  9. package/build/extensions/CodeEditor.js.map +1 -1
  10. package/build/extensions/DocMaker.d.ts +28 -28
  11. package/build/extensions/DocMaker.js +363 -363
  12. package/build/extensions/Timeline.d.ts +2 -2
  13. package/build/extensions/Timeline.js +28 -15
  14. package/build/extensions/Timeline.js.map +1 -1
  15. package/build/extensions/VideoEditor.d.ts +1 -1
  16. package/build/extensions/VideoEditor.js +15 -7
  17. package/build/extensions/VideoEditor.js.map +1 -1
  18. package/build/extensions/index.js +1 -1
  19. package/build/lexgui.all.js +6169 -6960
  20. package/build/lexgui.all.js.map +1 -1
  21. package/build/lexgui.all.min.js +1 -1
  22. package/build/lexgui.all.module.js +6169 -6961
  23. package/build/lexgui.all.module.js.map +1 -1
  24. package/build/lexgui.all.module.min.js +1 -1
  25. package/build/lexgui.css +7534 -7459
  26. package/build/lexgui.js +4475 -205
  27. package/build/lexgui.js.map +1 -1
  28. package/build/lexgui.min.css +1 -1
  29. package/build/lexgui.min.js +1 -1
  30. package/build/lexgui.module.js +4475 -205
  31. package/build/lexgui.module.js.map +1 -1
  32. package/build/lexgui.module.min.js +1 -1
  33. package/changelog.md +31 -1
  34. package/examples/code-editor.html +88 -16
  35. package/package.json +1 -1
@@ -6,13 +6,13 @@ import { extendTailwindMerge } from 'https://cdn.jsdelivr.net/npm/tailwind-merge
6
6
  * Main namespace
7
7
  * @namespace LX
8
8
  */
9
- const g = globalThis;
9
+ const g$1 = globalThis;
10
10
  // Update global namespace if not present (Loading module)
11
11
  // Extension scripts rely on LX being globally available
12
- let LX = g.LX;
12
+ let LX = g$1.LX;
13
13
  if (!LX) {
14
14
  LX = {
15
- version: '8.2.5',
15
+ version: '8.3.0',
16
16
  ready: false,
17
17
  extensions: [], // Store extensions used
18
18
  extraCommandbarEntries: [], // User specific entries for command bar
@@ -29,7 +29,7 @@ if (!LX) {
29
29
  CURVE_MOVEOUT_DELETE: 1,
30
30
  DRAGGABLE_Z_INDEX: 101
31
31
  };
32
- g.LX = LX;
32
+ g$1.LX = LX;
33
33
  }
34
34
 
35
35
  // Icons.ts @jxarco
@@ -1404,6 +1404,15 @@ class Tabs {
1404
1404
  }
1405
1405
  this.tabDOMs[name].click();
1406
1406
  }
1407
+ setIcon(name, icon) {
1408
+ const tabEl = this.tabDOMs[name];
1409
+ if (!tabEl) {
1410
+ return;
1411
+ }
1412
+ const classes = icon.split(' ');
1413
+ const iconEl = LX.makeIcon(classes[0], { svgClass: 'sm ' + classes.slice(0).join(' ') });
1414
+ tabEl.innerHTML = iconEl.innerHTML + name;
1415
+ }
1407
1416
  delete(name) {
1408
1417
  if (this.selected == name) {
1409
1418
  this.selected = null;
@@ -5093,80 +5102,82 @@ class NodeTree {
5093
5102
  };
5094
5103
  const r = await onContextMenu(event);
5095
5104
  const multiple = this.selected.length > 1;
5096
- LX.addContextMenu(multiple ? 'Selected Nodes' : node.id, e, (m) => {
5105
+ const useDefaultContextMenuItems = this.options.useDefaultContextMenuItems ?? true;
5106
+ LX.addContextMenu(this.options.contextMenuTitle ?? (multiple ? 'Selected Nodes' : node.id), e, (m) => {
5097
5107
  if (r?.length) {
5098
5108
  for (const i of r) {
5099
5109
  m.add(i.name, { callback: i.callback });
5100
5110
  }
5101
- m.add('');
5102
- }
5103
- m.add('Select Children', () => {
5104
- const selectChildren = (n) => {
5105
- if (n.closed) {
5106
- return;
5107
- }
5108
- for (let child of n.children ?? []) {
5109
- if (!child) {
5110
- continue;
5111
- }
5112
- let nodeItem = this.domEl.querySelector('#' + child.id);
5113
- nodeItem.classList.add('selected');
5114
- this.selected.push(child);
5115
- selectChildren(child);
5116
- }
5117
- };
5118
- this.domEl.querySelectorAll('.selected').forEach((i) => i.classList.remove('selected'));
5119
- this.selected.length = 0;
5120
- // Add childs of the clicked node
5121
- selectChildren(node);
5122
- const onSelect = this._callbacks['select'];
5123
- if (onSelect !== undefined) {
5124
- const event = {
5125
- type: 'select',
5126
- items: [node],
5127
- result: this.selected,
5128
- domEvent: e,
5129
- userInitiated: true
5130
- };
5131
- onSelect(event);
5111
+ if (useDefaultContextMenuItems) {
5112
+ m.add('');
5132
5113
  }
5133
- });
5134
- m.add('Delete', { callback: () => {
5135
- const onBeforeDelete = this._callbacks['beforeDelete'];
5136
- const onDelete = this._callbacks['delete'];
5137
- const resolve = (...args) => {
5138
- let deletedNodes = [];
5139
- if (this.selected.length) {
5140
- deletedNodes.push(...that.deleteNodes(this.selected));
5114
+ }
5115
+ if (useDefaultContextMenuItems) {
5116
+ m.add('Select Children', () => {
5117
+ const selectChildren = (n) => {
5118
+ if (n.closed) {
5119
+ return;
5141
5120
  }
5142
- else if (that.deleteNode(node)) {
5143
- deletedNodes.push(node);
5121
+ for (let child of n.children ?? []) {
5122
+ if (!child) {
5123
+ continue;
5124
+ }
5125
+ let nodeItem = this.domEl.querySelector('#' + child.id);
5126
+ nodeItem.classList.add('selected');
5127
+ this.selected.push(child);
5128
+ selectChildren(child);
5144
5129
  }
5145
- this.refresh();
5146
- const event = {
5147
- type: 'delete',
5148
- items: deletedNodes,
5149
- userInitiated: true
5150
- };
5151
- if (onDelete)
5152
- onDelete(event, ...args);
5153
5130
  };
5154
- if (onBeforeDelete) {
5131
+ this.domEl.querySelectorAll('.selected').forEach((i) => i.classList.remove('selected'));
5132
+ this.selected.length = 0;
5133
+ // Add childs of the clicked node
5134
+ selectChildren(node);
5135
+ const onSelect = this._callbacks['select'];
5136
+ if (onSelect !== undefined) {
5155
5137
  const event = {
5156
- type: 'delete',
5157
- items: this.selected.length ? this.selected : [node],
5138
+ type: 'select',
5139
+ items: [node],
5140
+ result: this.selected,
5141
+ domEvent: e,
5158
5142
  userInitiated: true
5159
5143
  };
5160
- onBeforeDelete(event, resolve);
5161
- }
5162
- else {
5163
- resolve();
5144
+ onSelect(event);
5164
5145
  }
5165
- } });
5146
+ });
5147
+ m.add('Delete', { callback: () => {
5148
+ const onBeforeDelete = this._callbacks['beforeDelete'];
5149
+ const onDelete = this._callbacks['delete'];
5150
+ const resolve = (...args) => {
5151
+ let deletedNodes = [];
5152
+ if (this.selected.length) {
5153
+ deletedNodes.push(...that.deleteNodes(this.selected));
5154
+ }
5155
+ else if (that.deleteNode(node)) {
5156
+ deletedNodes.push(node);
5157
+ }
5158
+ this.refresh();
5159
+ const event = {
5160
+ type: 'delete',
5161
+ items: deletedNodes,
5162
+ userInitiated: true
5163
+ };
5164
+ if (onDelete)
5165
+ onDelete(event, ...args);
5166
+ };
5167
+ if (onBeforeDelete) {
5168
+ const event = {
5169
+ type: 'delete',
5170
+ items: this.selected.length ? this.selected : [node],
5171
+ userInitiated: true
5172
+ };
5173
+ onBeforeDelete(event, resolve);
5174
+ }
5175
+ else {
5176
+ resolve();
5177
+ }
5178
+ } });
5179
+ }
5166
5180
  });
5167
- if (!(this.options.addDefault ?? false)) {
5168
- return;
5169
- }
5170
5181
  });
5171
5182
  item.addEventListener('keydown', (e) => {
5172
5183
  if (node.rename) {
@@ -5848,7 +5859,7 @@ class Progress extends BaseComponent {
5848
5859
  progress.value = value;
5849
5860
  container.appendChild(progress);
5850
5861
  const _updateColor = () => {
5851
- let backgroundColor = LX.getCSSVariable('blue-500');
5862
+ let backgroundColor = LX.getCSSVariable('color-blue-500');
5852
5863
  if (progress.low != undefined && progress.value < progress.low) {
5853
5864
  backgroundColor = LX.getCSSVariable('destructive');
5854
5865
  }
@@ -11332,6 +11343,42 @@ Object.assign(LX, {
11332
11343
  xhr.send(data);
11333
11344
  return xhr;
11334
11345
  },
11346
+ /**
11347
+ * Request file with a promise from url (it could be a binary, text, etc.). If you want a simplied version use
11348
+ * @method requestFileAsync
11349
+ * @param {String} url
11350
+ * @param {String} dataType
11351
+ * @param {Boolean} nocache
11352
+ */
11353
+ async requestFileAsync(url, dataType, nocache = false) {
11354
+ return new Promise((resolve, reject) => {
11355
+ dataType = dataType ?? 'arraybuffer';
11356
+ const mimeType = dataType === 'arraybuffer' ? 'application/octet-stream' : undefined;
11357
+ var xhr = new XMLHttpRequest();
11358
+ xhr.open('GET', url, true);
11359
+ xhr.responseType = dataType;
11360
+ if (mimeType) {
11361
+ xhr.overrideMimeType(mimeType);
11362
+ }
11363
+ if (nocache) {
11364
+ xhr.setRequestHeader('Cache-Control', 'no-cache');
11365
+ }
11366
+ xhr.onload = function () {
11367
+ var response = this.response;
11368
+ if (this.status != 200) {
11369
+ var err = 'Error ' + this.status;
11370
+ reject(err);
11371
+ return;
11372
+ }
11373
+ resolve(response);
11374
+ };
11375
+ xhr.onerror = function (err) {
11376
+ reject(err);
11377
+ };
11378
+ xhr.send();
11379
+ return xhr;
11380
+ });
11381
+ },
11335
11382
  /**
11336
11383
  * Request file from url
11337
11384
  * @method requestText
@@ -11755,7 +11802,12 @@ class AlertDialog extends Dialog {
11755
11802
  LX.makeContainer(['100%', '100%'], 'text-lg font-medium text-foreground px-2', title, p);
11756
11803
  p.addTextArea(null, message, null, { disabled: true, fitHeight: true, inputClass: 'bg-none text-sm text-muted-foreground' });
11757
11804
  p.sameLine(2, 'justify-end');
11758
- p.addButton(null, options.cancelText ?? 'Cancel', () => this.destroy(), {
11805
+ p.addButton(null, options.cancelText ?? 'Cancel', () => {
11806
+ if (options.cancelCallback) {
11807
+ options.cancelCallback();
11808
+ }
11809
+ this.destroy();
11810
+ }, {
11759
11811
  buttonClass: 'outline'
11760
11812
  });
11761
11813
  p.addButton(null, options.continueText ?? 'Continue', () => {
@@ -12160,7 +12212,7 @@ class DropdownMenu {
12160
12212
  }
12161
12213
  else if (item.kbd) {
12162
12214
  item.kbd = [].concat(item.kbd);
12163
- const kbd = LX.makeKbd(item.kbd);
12215
+ const kbd = LX.makeKbd(item.kbd, item.useKbdSpecialKeys ?? true);
12164
12216
  menuItem.appendChild(kbd);
12165
12217
  document.addEventListener('keydown', (e) => {
12166
12218
  if (!this._trigger.ddm)
@@ -13403,152 +13455,4365 @@ class Tour {
13403
13455
  }
13404
13456
  LX.Tour = Tour;
13405
13457
 
13406
- // Core.ts @jxarco
13407
- /**
13408
- * @method init
13409
- * @param {Object} options
13410
- * autoTheme: Use theme depending on browser-system default theme [true]
13411
- * container: Root location for the gui (default is the document body)
13412
- * id: Id of the main area
13413
- * rootClass: Extra class to the root container
13414
- * skipRoot: Skip adding LX root container
13415
- * skipDefaultArea: Skip creation of main area
13416
- * layoutMode: Sets page layout mode (document | app)
13417
- * spacingMode: Sets page layout spacing mode (default | compact)
13418
- */
13419
- LX.init = async function (options = {}) {
13420
- if (this.ready) {
13421
- return this.mainArea;
13458
+ // CodeEditor.ts @jxarco
13459
+ if (!LX) {
13460
+ throw ('Missing LX namespace!');
13461
+ }
13462
+ LX.extensions.push('CodeEditor');
13463
+ // _____ _ _ _ _ _ _
13464
+ // | | | |_|_| |_| |_|_|___ ___
13465
+ // | | | _| | | | _| | -_|_ -|
13466
+ // |_____|_| |_|_|_|_| |_|___|___|
13467
+ function firstNonspaceIndex(str) {
13468
+ const index = str.search(/\S|$/);
13469
+ return index < str.length ? index : -1;
13470
+ }
13471
+ function isSymbol(str) {
13472
+ return /[^\w\s]/.test(str);
13473
+ }
13474
+ function isWord(str) {
13475
+ return /\w/.test(str);
13476
+ }
13477
+ function getLanguageIcon(langDef, extension) {
13478
+ if (!langDef?.icon) {
13479
+ return 'FileCode text-neutral-500'; // default icon
13422
13480
  }
13423
- await LX.loadScriptSync('https://unpkg.com/lucide@latest');
13424
- // LexGUI root
13425
- console.log(`LexGUI v${this.version}`);
13426
- const root = LX.makeElement('div', LX.mergeClass('lexcontainer', options.rootClass));
13427
- root.id = 'lexroot';
13428
- root.tabIndex = -1;
13429
- this.modal = LX.makeElement('div', 'inset-0 hidden-opacity bg-black/50 fixed z-100 transition-opacity duration-100 ease-in');
13430
- this.modal.id = 'modal';
13431
- this.modal.toggle = function (force) {
13432
- this.classList.toggle('hidden-opacity', force);
13433
- };
13434
- function blockScroll(e) {
13435
- e.preventDefault();
13436
- e.stopPropagation();
13481
+ if (typeof langDef.icon === 'string') {
13482
+ return langDef.icon;
13437
13483
  }
13438
- this.modal.addEventListener('wheel', blockScroll, { passive: false });
13439
- this.modal.addEventListener('touchmove', blockScroll, { passive: false });
13440
- this.root = root;
13441
- this.container = document.body;
13442
- if (options.container) {
13443
- this.container = options.container.constructor === String
13444
- ? document.getElementById(options.container)
13445
- : options.container;
13484
+ if (extension && langDef.icon[extension]) {
13485
+ return langDef.icon[extension];
13446
13486
  }
13447
- this.layoutMode = options.layoutMode ?? 'app';
13448
- document.documentElement.setAttribute('data-layout', this.layoutMode);
13449
- if (this.layoutMode == 'document') ;
13450
- this.spacingMode = options.spacingMode ?? 'default';
13451
- document.documentElement.setAttribute('data-spacing', this.spacingMode);
13452
- this.container.appendChild(this.modal);
13453
- if (!options.skipRoot) {
13454
- this.container.appendChild(root);
13487
+ const firstIcon = Object.values(langDef.icon)[0];
13488
+ return firstIcon || 'FileCode text-neutral-500';
13489
+ }
13490
+ // _____ _ _
13491
+ // |_ _|___| |_ ___ ___|_|___ ___ ___
13492
+ // | | | . | '_| -_| | |- _| -_| _|
13493
+ // |_| |___|_,_|___|_|_|_|___|___|_|
13494
+ class Tokenizer {
13495
+ static languages = new Map();
13496
+ static extensionMap = new Map(); // ext -> language name
13497
+ static registerLanguage(def) {
13498
+ Tokenizer.languages.set(def.name, def);
13499
+ for (const ext of def.extensions) {
13500
+ Tokenizer.extensionMap.set(ext, def.name);
13501
+ }
13455
13502
  }
13456
- else {
13457
- this.root = document.body;
13503
+ static getLanguage(name) {
13504
+ return Tokenizer.languages.get(name);
13458
13505
  }
13459
- // Notifications
13460
- {
13461
- const notifSection = document.createElement('section');
13462
- notifSection.className = 'notifications';
13463
- this.notifications = document.createElement('ol');
13464
- this.notifications.className = 'fixed flex flex-col-reverse m-0 p-0 gap-1 z-1000';
13465
- this.notifications.iWidth = 0;
13466
- notifSection.appendChild(this.notifications);
13467
- document.body.appendChild(notifSection);
13468
- this.notifications.addEventListener('mouseenter', () => {
13469
- this.notifications.classList.add('list');
13470
- });
13471
- this.notifications.addEventListener('mouseleave', () => {
13472
- this.notifications.classList.remove('list');
13473
- });
13506
+ static getLanguageByExtension(ext) {
13507
+ const name = Tokenizer.extensionMap.get(ext);
13508
+ return name ? Tokenizer.languages.get(name) : undefined;
13474
13509
  }
13475
- // Disable drag icon
13476
- root.addEventListener('dragover', function (e) {
13477
- e.preventDefault();
13478
- }, false);
13479
- document.addEventListener('contextmenu', function (e) {
13480
- e.preventDefault();
13481
- }, false);
13482
- // Global vars
13483
- this.DEFAULT_NAME_WIDTH = '30%';
13484
- this.DEFAULT_SPLITBAR_SIZE = 4;
13485
- this.OPEN_CONTEXTMENU_ENTRY = 'click';
13486
- this.componentResizeObserver = new ResizeObserver((entries) => {
13487
- for (const entry of entries) {
13488
- const c = entry.target?.jsInstance;
13489
- if (c && c.onResize) {
13490
- c.onResize(entry.contentRect);
13510
+ static getRegisteredLanguages() {
13511
+ return Array.from(Tokenizer.languages.keys());
13512
+ }
13513
+ static initialState() {
13514
+ return { stack: ['root'] };
13515
+ }
13516
+ /**
13517
+ * Tokenize a single line given a language and the state from the previous line.
13518
+ * Returns the tokens and the updated state for the next line.
13519
+ */
13520
+ static tokenizeLine(line, language, state) {
13521
+ const tokens = [];
13522
+ const stack = [...state.stack]; // clone
13523
+ let pos = 0;
13524
+ while (pos < line.length) {
13525
+ const currentState = stack[stack.length - 1];
13526
+ const rules = language.states[currentState];
13527
+ if (!rules) {
13528
+ // No rules for this state, so emit rest as text
13529
+ tokens.push({ type: 'text', value: line.slice(pos) });
13530
+ pos = line.length;
13531
+ break;
13532
+ }
13533
+ let matched = false;
13534
+ for (const rule of rules) {
13535
+ // Anchor regex at current position
13536
+ const regex = new RegExp(rule.match.source, 'y' + (rule.match.flags.replace(/[gy]/g, '')));
13537
+ regex.lastIndex = pos;
13538
+ const m = regex.exec(line);
13539
+ if (m) {
13540
+ if (m[0].length === 0) {
13541
+ // Zero-length match, skipping to avoid infinite loop...
13542
+ continue;
13543
+ }
13544
+ tokens.push({ type: rule.type, value: m[0] });
13545
+ pos += m[0].length;
13546
+ // State transitions
13547
+ if (rule.next) {
13548
+ stack.push(rule.next);
13549
+ }
13550
+ else if (rule.pop) {
13551
+ const popCount = typeof rule.pop === 'number' ? rule.pop : 1;
13552
+ for (let i = 0; i < popCount && stack.length > 1; i++) {
13553
+ stack.pop();
13554
+ }
13555
+ }
13556
+ matched = true;
13557
+ break;
13558
+ }
13559
+ }
13560
+ if (!matched) {
13561
+ // No rule matched, consume one character as text either merged or as a new token
13562
+ const lastToken = tokens[tokens.length - 1];
13563
+ if (lastToken && lastToken.type === 'text') {
13564
+ lastToken.value += line[pos];
13565
+ }
13566
+ else {
13567
+ tokens.push({ type: 'text', value: line[pos] });
13568
+ }
13569
+ pos++;
13491
13570
  }
13492
13571
  }
13493
- });
13494
- this.ready = true;
13495
- this.menubars = [];
13496
- this.sidebars = [];
13497
- this.commandbar = this._createCommandbar(this.container);
13498
- if (!options.skipRoot && !options.skipDefaultArea) {
13499
- this.mainArea = new Area({ id: options.id ?? 'mainarea' });
13500
- }
13501
- // Initial or automatic changes don't force color scheme
13502
- // to be stored in localStorage
13503
- this._onChangeSystemTheme = function (event) {
13504
- const storedcolorScheme = localStorage.getItem('lxColorScheme');
13505
- if (storedcolorScheme)
13506
- return;
13507
- LX.setMode(event.matches ? 'dark' : 'light', false);
13508
- };
13509
- this._mqlPrefersDarkScheme = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
13510
- const storedcolorScheme = localStorage.getItem('lxColorScheme');
13511
- if (storedcolorScheme) {
13512
- LX.setMode(storedcolorScheme);
13572
+ const merged = Tokenizer._mergeTokens(tokens);
13573
+ return { tokens: merged, state: { stack } };
13513
13574
  }
13514
- else if (this._mqlPrefersDarkScheme && (options.autoTheme ?? true)) {
13515
- if (window.matchMedia('(prefers-color-scheme: light)').matches) {
13516
- LX.setMode('light', false);
13575
+ /**
13576
+ * Merge consecutive tokens with the same type into one.
13577
+ */
13578
+ static _mergeTokens(tokens) {
13579
+ if (tokens.length === 0)
13580
+ return tokens;
13581
+ const result = [tokens[0]];
13582
+ for (let i = 1; i < tokens.length; i++) {
13583
+ const prev = result[result.length - 1];
13584
+ if (tokens[i].type === prev.type) {
13585
+ prev.value += tokens[i].value;
13586
+ }
13587
+ else {
13588
+ result.push(tokens[i]);
13589
+ }
13517
13590
  }
13518
- this._mqlPrefersDarkScheme.addEventListener('change', this._onChangeSystemTheme);
13591
+ return result;
13519
13592
  }
13520
- // LX.setThemeColor( 'rose' );
13521
- return this.mainArea;
13522
- };
13593
+ }
13594
+ LX.Tokenizer = Tokenizer;
13595
+ // __ _____ _
13596
+ // | | ___ ___ ___ _ _ ___ ___ ___ | | |___| |___ ___ ___ ___
13597
+ // | |__| .'| | . | | | .'| . | -_| | | -_| | . | -_| _|_ -|
13598
+ // |_____|__,|_|_|_ |___|__,|_ |___| |__|__|___|_| _|___|_| |___|
13599
+ // |___| |___| |_|
13523
13600
  /**
13524
- * @method setSpacingMode
13525
- * @param {String} mode: "default" | "compact"
13601
+ * Build a word-boundary regex from a list of words to use as a language rule "match".
13602
+ * e.g. words(['const', 'let', 'var']) → /\b(?:const|let|var)\b/
13526
13603
  */
13527
- LX.setSpacingMode = function (mode) {
13528
- this.spacingMode = mode;
13529
- document.documentElement.setAttribute('data-spacing', this.spacingMode);
13530
- };
13604
+ function words(list) {
13605
+ return new RegExp('\\b(?:' + list.join('|') + ')\\b');
13606
+ }
13531
13607
  /**
13532
- * @method setLayoutMode
13533
- * @param {String} mode: "app" | "document"
13608
+ * Common state rules reusable across C-like languages.
13534
13609
  */
13535
- LX.setLayoutMode = function (mode) {
13536
- this.layoutMode = mode;
13537
- document.documentElement.setAttribute('data-layout', this.layoutMode);
13610
+ const CommonStates = {
13611
+ blockComment: [
13612
+ { match: /\*\//, type: 'comment', pop: true },
13613
+ { match: /[^*]+/, type: 'comment' },
13614
+ { match: /\*/, type: 'comment' },
13615
+ ],
13616
+ doubleString: [
13617
+ { match: /\\./, type: 'string' },
13618
+ { match: /"/, type: 'string', pop: true },
13619
+ { match: /[^"\\]+/, type: 'string' },
13620
+ ],
13621
+ singleString: [
13622
+ { match: /\\./, type: 'string' },
13623
+ { match: /'/, type: 'string', pop: true },
13624
+ { match: /[^'\\]+/, type: 'string' },
13625
+ ],
13538
13626
  };
13539
- /**
13540
- * @method addSignal
13541
- * @param {String} name
13542
- * @param {Object} obj
13543
- * @param {Function} callback
13544
- */
13545
- LX.addSignal = function (name, obj, callback) {
13546
- obj[name] = callback;
13547
- if (!LX.signals[name]) {
13548
- LX.signals[name] = [];
13549
- }
13550
- if (LX.signals[name].indexOf(obj) > -1) {
13551
- return;
13627
+ /** Common number rules for C-like languages. */
13628
+ const NumberRules = [
13629
+ // Binary: 0b1010, 0B1010
13630
+ { match: /0[bB][01]+(?:[uU][lL]{0,2}|[lL]{1,2}[uU]?)?\b/, type: 'number' },
13631
+ // Hex: 0xFF, 0xDEADBEEF, 0xFFu, 0xFFul, 0xFFull
13632
+ { match: /0[xX][0-9a-fA-F]+(?:[uU][lL]{0,2}|[lL]{1,2}[uU]?)?\b/, type: 'number' },
13633
+ // Octal: 0o77, 0O77
13634
+ { match: /0[oO][0-7]+(?:[uU][lL]{0,2}|[lL]{1,2}[uU]?)?\b/, type: 'number' },
13635
+ // Decimal with optional suffix: 123, 123.456, 1.23e10, 0.5f, 16u, 100L, 42ul, 3.14d, 100m
13636
+ { match: /\d+\.?\d*(?:[eE][+-]?\d+)?(?:[fFdDmMlLuUiI]|[uU][lL]{0,2}|[lL]{1,2}[uU]?)?\b/, type: 'number' },
13637
+ // Decimal starting with dot: .123, .5f
13638
+ { match: /\.\d+(?:[eE][+-]?\d+)?(?:[fFdDmM])?\b/, type: 'number' },
13639
+ ];
13640
+ /** Common tail rules: method detection, identifiers, symbols, whitespace. */
13641
+ const TailRules = [
13642
+ { match: /[a-zA-Z_$]\w*(?=\s*[<(])/, type: 'method' }, // function/method names (followed by < or ()
13643
+ { match: /[a-zA-Z_$]\w*/, type: 'text' },
13644
+ { match: /[{}()\[\];,.:?!&|<>=+\-*/%^~@#]/, type: 'symbol' },
13645
+ { match: /\s+/, type: 'text' },
13646
+ ];
13647
+ /** Template string states for JS/TS. */
13648
+ function templateStringStates(exprKeywords) {
13649
+ return {
13650
+ templateString: [
13651
+ { match: /\\./, type: 'string' },
13652
+ { match: /`/, type: 'string', pop: true },
13653
+ { match: /\$\{/, type: 'symbol', next: 'templateExpr' },
13654
+ { match: /[^`\\$]+/, type: 'string' },
13655
+ { match: /\$/, type: 'string' },
13656
+ ],
13657
+ templateExpr: [
13658
+ { match: /\}/, type: 'symbol', pop: true },
13659
+ { match: /\/\*/, type: 'comment', next: 'blockComment' },
13660
+ { match: /\/\/.*/, type: 'comment' },
13661
+ { match: /"/, type: 'string', next: 'doubleString' },
13662
+ { match: /'/, type: 'string', next: 'singleString' },
13663
+ { match: /`/, type: 'string', next: 'templateString' },
13664
+ ...NumberRules,
13665
+ { match: words(exprKeywords), type: 'keyword' },
13666
+ ...TailRules,
13667
+ ],
13668
+ };
13669
+ }
13670
+ // __ ____ ___ _ _ _ _
13671
+ // | | ___ ___ ___ _ _ ___ ___ ___ | \ ___| _|_|___|_| |_|_|___ ___ ___
13672
+ // | |__| .'| | . | | | .'| . | -_| | | | -_| _| | | | _| | . | |_ -|
13673
+ // |_____|__,|_|_|_ |___|__,|_ |___| |____/|___|_| |_|_|_|_|_| |_|___|_|_|___|
13674
+ // |___| |___|
13675
+ Tokenizer.registerLanguage({
13676
+ name: 'Plain Text',
13677
+ extensions: ['txt'],
13678
+ states: {
13679
+ root: [
13680
+ { match: /.+/, type: 'text' }
13681
+ ]
13682
+ },
13683
+ reservedWords: [],
13684
+ icon: 'FileText text-neutral-500'
13685
+ });
13686
+ // JavaScript
13687
+ const jsKeywords = [
13688
+ 'var', 'let', 'const', 'this', 'in', 'of', 'true', 'false', 'null', 'undefined',
13689
+ 'new', 'function', 'class', 'extends', 'super', 'import', 'export', 'from',
13690
+ 'default', 'async', 'typeof', 'instanceof', 'void', 'delete', 'debugger', 'NaN',
13691
+ 'static', 'constructor', 'Infinity', 'abstract'
13692
+ ];
13693
+ const jsStatements = [
13694
+ 'for', 'if', 'else', 'switch', 'case', 'return', 'while', 'do', 'continue', 'break',
13695
+ 'await', 'yield', 'throw', 'try', 'catch', 'finally', 'with'
13696
+ ];
13697
+ const jsBuiltins = [
13698
+ 'document', 'console', 'window', 'navigator', 'performance',
13699
+ 'Math', 'JSON', 'Promise', 'Array', 'Object', 'String', 'Number', 'Boolean',
13700
+ 'RegExp', 'Error', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Symbol', 'Proxy', 'Reflect'
13701
+ ];
13702
+ Tokenizer.registerLanguage({
13703
+ name: 'JavaScript',
13704
+ extensions: ['js', 'mjs', 'cjs'],
13705
+ lineComment: '//',
13706
+ states: {
13707
+ root: [
13708
+ { match: /\/\*/, type: 'comment', next: 'blockComment' },
13709
+ { match: /\/\/.*/, type: 'comment' },
13710
+ { match: /`/, type: 'string', next: 'templateString' },
13711
+ { match: /"/, type: 'string', next: 'doubleString' },
13712
+ { match: /'/, type: 'string', next: 'singleString' },
13713
+ ...NumberRules,
13714
+ { match: words(jsKeywords), type: 'keyword' },
13715
+ { match: words(jsBuiltins), type: 'builtin' },
13716
+ { match: words(jsStatements), type: 'statement' },
13717
+ { match: /(?<=\b(?:class|enum)\s+)[A-Z][a-zA-Z0-9_]*/, type: 'type' }, // class/enum names
13718
+ ...TailRules,
13719
+ ],
13720
+ ...CommonStates,
13721
+ ...templateStringStates(['var', 'let', 'const', 'this', 'true', 'false', 'null', 'undefined', 'new', 'typeof', 'instanceof', 'void']),
13722
+ },
13723
+ reservedWords: [...jsKeywords, ...jsStatements, ...jsBuiltins],
13724
+ icon: 'Js text-yellow-500'
13725
+ });
13726
+ // TypeScript
13727
+ const tsKeywords = [
13728
+ ...jsKeywords,
13729
+ 'as', 'interface', 'type', 'enum', 'namespace', 'declare', 'private', 'protected',
13730
+ 'implements', 'readonly', 'keyof', 'infer', 'is', 'asserts', 'override', 'satisfies'
13731
+ ];
13732
+ const tsTypes = [
13733
+ 'string', 'number', 'boolean', 'any', 'unknown', 'never', 'void', 'null',
13734
+ 'undefined', 'object', 'symbol', 'bigint', 'Promise',
13735
+ 'Record', 'Partial', 'Required', 'Readonly', 'Pick', 'Omit', 'Exclude',
13736
+ 'Extract', 'NonNullable', 'ReturnType', 'Parameters', 'ConstructorParameters',
13737
+ 'InstanceType', 'Awaited'
13738
+ ];
13739
+ Tokenizer.registerLanguage({
13740
+ name: 'TypeScript',
13741
+ extensions: ['ts', 'tsx'],
13742
+ lineComment: '//',
13743
+ states: {
13744
+ root: [
13745
+ { match: /\/\*/, type: 'comment', next: 'blockComment' },
13746
+ { match: /\/\/.*/, type: 'comment' },
13747
+ { match: /`/, type: 'string', next: 'templateString' },
13748
+ { match: /"/, type: 'string', next: 'doubleString' },
13749
+ { match: /'/, type: 'string', next: 'singleString' },
13750
+ ...NumberRules,
13751
+ { match: words(tsKeywords), type: 'keyword' },
13752
+ { match: words(tsTypes), type: 'type' },
13753
+ { match: words(jsBuiltins), type: 'builtin' },
13754
+ { match: words(jsStatements), type: 'statement' },
13755
+ { match: /(?<=\b(?:class|enum|interface|type|extends|implements)\s+)[A-Z][a-zA-Z0-9_]*/, type: 'type' }, // class/enum/interface/type names
13756
+ { match: /(?<=<\s*)[A-Z][a-zA-Z0-9_]*(?=\s*(?:[,>]|extends|=))/, type: 'type' }, // type parameters in generics <T>
13757
+ { match: /(?<=,\s*)[A-Z][a-zA-Z0-9_]*(?=\s*(?:[,>]|extends|=))/, type: 'type' }, // type parameters after comma <T, K>
13758
+ { match: /(?<=:\s*)[A-Z][a-zA-Z0-9_]*/, type: 'type' }, // type annotations after colon
13759
+ ...TailRules,
13760
+ ],
13761
+ ...CommonStates,
13762
+ ...templateStringStates(['var', 'let', 'const', 'this', 'true', 'false', 'null', 'undefined', 'new', 'typeof', 'instanceof', 'void']),
13763
+ },
13764
+ reservedWords: [...tsKeywords, ...tsTypes, ...jsBuiltins, ...jsStatements],
13765
+ icon: 'Ts text-blue-600'
13766
+ });
13767
+ // WGSL (WebGPU Shading Language)
13768
+ const wgslKeywords = [
13769
+ 'bool', 'i32', 'u32', 'f16', 'f32', 'vec2', 'vec3', 'vec4', 'vec2i', 'vec3i', 'vec4i',
13770
+ 'vec2u', 'vec3u', 'vec4u', 'vec2f', 'vec3f', 'vec4f', 'mat2x2f', 'mat2x3f', 'mat2x4f', 'mat3x2f', 'mat3x3f',
13771
+ 'mat3x4f', 'mat4x2f', 'mat4x3f', 'mat4x4f', 'array', 'struct', 'ptr', 'atomic', 'sampler', 'sampler_comparison',
13772
+ 'texture_1d', 'texture_2d', 'texture_2d_array', 'texture_3d', 'texture_cube', 'texture_cube_array', 'texture_multisampled_2d',
13773
+ 'texture_depth_2d', 'texture_depth_2d_array', 'texture_depth_cube', 'texture_depth_cube_array',
13774
+ 'texture_depth_multisampled_2d', 'texture_storage_1d', 'texture_storage_2d', 'texture_storage_2d_array',
13775
+ 'texture_storage_3d', 'texture_external', 'var', 'let', 'const', 'override', 'fn', 'type', 'alias',
13776
+ 'true', 'false'
13777
+ ];
13778
+ const wgslStatements = [
13779
+ 'if', 'else', 'switch', 'case', 'default', 'for', 'loop', 'while', 'break', 'continue', 'discard',
13780
+ 'return', 'function', 'private', 'workgroup', 'uniform', 'storage', 'read', 'write', 'read_write', 'bitcast'
13781
+ ];
13782
+ const wgslBuiltins = [
13783
+ 'position', 'vertex_index', 'instance_index', 'front_facing', 'frag_depth',
13784
+ 'local_invocation_id', 'local_invocation_index', 'global_invocation_id', 'workgroup_id', 'num_workgroups',
13785
+ 'abs', 'acos', 'acosh', 'asin', 'asinh', 'atan', 'atanh', 'atan2', 'ceil', 'clamp', 'cos', 'cosh',
13786
+ 'cross', 'degrees', 'determinant', 'distance', 'dot', 'exp', 'exp2', 'floor', 'fma', 'fract', 'inverseSqrt',
13787
+ 'length', 'log', 'log2', 'max', 'min', 'mix', 'normalize', 'pow', 'radians', 'reflect', 'refract', 'round',
13788
+ 'saturate', 'sign', 'sin', 'sinh', 'smoothstep', 'sqrt', 'step', 'tan', 'tanh', 'transpose', 'trunc',
13789
+ 'textureSample', 'textureSampleBias', 'textureSampleLevel', 'textureSampleGrad',
13790
+ 'textureSampleCompare', 'textureSampleCompareLevel', 'textureSampleBaseClampToEdge',
13791
+ 'textureLoad', 'textureStore', 'textureGather', 'textureGatherCompare',
13792
+ 'textureDimensions', 'textureNumLayers', 'textureNumLevels', 'textureNumSamples',
13793
+ 'pack4x8snorm', 'pack4x8unorm', 'pack2x16snorm', 'pack2x16unorm', 'pack2x16float',
13794
+ 'unpack4x8snorm', 'unpack4x8unorm', 'unpack2x16snorm', 'unpack2x16unorm', 'unpack2x16float',
13795
+ 'atomicLoad', 'atomicStore', 'atomicAdd', 'atomicSub', 'atomicMax', 'atomicMin',
13796
+ 'atomicAnd', 'atomicOr', 'atomicXor', 'atomicExchange', 'atomicCompareExchangeWeak',
13797
+ 'dpdx', 'dpdxCoarse', 'dpdxFine', 'dpdy', 'dpdyCoarse', 'dpdyFine', 'fwidth', 'fwidthCoarse', 'fwidthFine',
13798
+ 'select', 'arrayLength', 'countLeadingZeros', 'countOneBits', 'countTrailingZeros',
13799
+ 'extractBits', 'firstLeadingBit', 'firstTrailingBit', 'insertBits', 'reverseBits',
13800
+ 'storageBarrier', 'workgroupBarrier', 'workgroupUniformLoad'
13801
+ ];
13802
+ Tokenizer.registerLanguage({
13803
+ name: 'WGSL',
13804
+ extensions: ['wgsl'],
13805
+ lineComment: '//',
13806
+ states: {
13807
+ root: [
13808
+ { match: /\/\*/, type: 'comment', next: 'blockComment' },
13809
+ { match: /\/\/.*/, type: 'comment' },
13810
+ { match: /#\w+/, type: 'preprocessor' },
13811
+ ...NumberRules,
13812
+ { match: words(wgslKeywords), type: 'keyword' },
13813
+ { match: words(wgslBuiltins), type: 'builtin' },
13814
+ { match: words(wgslStatements), type: 'statement' },
13815
+ { match: /@\w+/, type: 'text' },
13816
+ ...TailRules,
13817
+ ],
13818
+ ...CommonStates
13819
+ },
13820
+ reservedWords: [...wgslKeywords, ...wgslBuiltins, ...wgslStatements],
13821
+ icon: 'AlignLeft text-orange-500'
13822
+ });
13823
+ // GLSL (OpenGL/WebGL Shading Language)
13824
+ const glslKeywords = [
13825
+ 'true', 'false', 'int', 'float', 'double', 'bool', 'void', 'uint', 'struct', 'mat2', 'mat3',
13826
+ 'mat4', 'mat2x2', 'mat2x3', 'mat2x4', 'mat3x2', 'mat3x3', 'mat3x4', 'mat4x2', 'mat4x3', 'mat4x4',
13827
+ 'vec2', 'vec3', 'vec4', 'ivec2', 'ivec3', 'ivec4', 'uvec2', 'uvec3', 'uvec4', 'dvec2', 'dvec3',
13828
+ 'dvec4', 'bvec2', 'bvec3', 'bvec4', 'sampler1D', 'sampler2D', 'sampler3D', 'samplerCube',
13829
+ 'sampler2DShadow', 'samplerCubeShadow', 'sampler2DArray', 'sampler2DArrayShadow',
13830
+ 'samplerCubeArray', 'samplerCubeArrayShadow', 'isampler2D', 'usampler2D', 'isampler3D',
13831
+ 'usampler3D', 'lowp', 'mediump', 'highp', 'precision', 'in', 'out', 'inout', 'uniform',
13832
+ 'varying', 'attribute', 'const', 'layout', 'centroid', 'flat', 'smooth', 'noperspective',
13833
+ 'patch', 'sample', 'buffer', 'shared', 'coherent', 'volatile', 'restrict', 'readonly', 'writeonly'
13834
+ ];
13835
+ const glslStatements = [
13836
+ 'if', 'else', 'switch', 'case', 'default', 'for', 'while', 'do', 'break', 'continue',
13837
+ 'return', 'discard'
13838
+ ];
13839
+ const glslBuiltins = [
13840
+ 'radians', 'degrees', 'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'pow', 'exp', 'log',
13841
+ 'exp2', 'log2', 'sqrt', 'inversesqrt', 'abs', 'sign', 'floor', 'ceil', 'fract',
13842
+ 'mod', 'min', 'max', 'clamp', 'mix', 'step', 'smoothstep', 'length', 'distance',
13843
+ 'dot', 'cross', 'normalize', 'reflect', 'refract', 'matrixCompMult',
13844
+ 'lessThan', 'lessThanEqual', 'greaterThan', 'greaterThanEqual',
13845
+ 'equal', 'notEqual', 'any', 'all', 'not', 'texture', 'textureProj',
13846
+ 'textureLod', 'textureGrad', 'texelFetch'
13847
+ ];
13848
+ Tokenizer.registerLanguage({
13849
+ name: 'GLSL',
13850
+ extensions: ['glsl'],
13851
+ lineComment: '//',
13852
+ states: {
13853
+ root: [
13854
+ { match: /\/\*/, type: 'comment', next: 'blockComment' },
13855
+ { match: /\/\/.*/, type: 'comment' },
13856
+ { match: /#\w+/, type: 'preprocessor' },
13857
+ ...NumberRules,
13858
+ { match: words(glslKeywords), type: 'keyword' },
13859
+ { match: words(glslBuiltins), type: 'builtin' },
13860
+ { match: words(glslStatements), type: 'statement' },
13861
+ ...TailRules,
13862
+ ],
13863
+ ...CommonStates
13864
+ },
13865
+ reservedWords: [...glslKeywords, ...glslBuiltins, ...glslStatements],
13866
+ icon: 'AlignLeft text-neutral-500'
13867
+ });
13868
+ // HLSL (DirectX Shader Language)
13869
+ const hlslKeywords = [
13870
+ 'bool', 'int', 'uint', 'dword', 'half', 'float', 'double', 'min16float', 'min10float', 'min16int', 'min12int', 'min16uint',
13871
+ 'float1', 'float2', 'float3', 'float4', 'int1', 'int2', 'int3', 'int4', 'uint1', 'uint2', 'uint3', 'uint4',
13872
+ 'bool1', 'bool2', 'bool3', 'bool4', 'half1', 'half2', 'half3', 'half4',
13873
+ 'float1x1', 'float1x2', 'float1x3', 'float1x4', 'float2x1', 'float2x2', 'float2x3', 'float2x4',
13874
+ 'float3x1', 'float3x2', 'float3x3', 'float3x4', 'float4x1', 'float4x2', 'float4x3', 'float4x4',
13875
+ 'vector', 'matrix', 'string', 'void', 'struct', 'class', 'interface', 'true', 'false',
13876
+ 'sampler', 'sampler1D', 'sampler2D', 'sampler3D', 'samplerCUBE', 'sampler_state',
13877
+ 'Texture1D', 'Texture2D', 'Texture3D', 'TextureCube', 'Texture1DArray', 'Texture2DArray', 'TextureCubeArray',
13878
+ 'Buffer', 'AppendStructuredBuffer', 'ConsumeStructuredBuffer', 'StructuredBuffer', 'RWStructuredBuffer',
13879
+ 'ByteAddressBuffer', 'RWByteAddressBuffer', 'RWTexture1D', 'RWTexture2D', 'RWTexture3D', 'RWTexture1DArray', 'RWTexture2DArray',
13880
+ 'cbuffer', 'tbuffer', 'in', 'out', 'inout', 'uniform', 'extern', 'static', 'volatile', 'precise', 'shared', 'groupshared',
13881
+ 'linear', 'centroid', 'nointerpolation', 'noperspective', 'sample', 'const', 'row_major', 'column_major'
13882
+ ];
13883
+ const hlslStatements = [
13884
+ 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'default', 'break', 'continue', 'discard', 'return',
13885
+ 'typedef', 'register', 'packoffset'
13886
+ ];
13887
+ const hlslBuiltins = [
13888
+ 'SV_Position', 'SV_Target', 'SV_Depth', 'SV_VertexID', 'SV_InstanceID', 'SV_PrimitiveID', 'SV_DispatchThreadID',
13889
+ 'SV_GroupID', 'SV_GroupThreadID', 'SV_GroupIndex', 'SV_Coverage', 'SV_IsFrontFace', 'SV_RenderTargetArrayIndex',
13890
+ 'POSITION', 'NORMAL', 'TEXCOORD', 'COLOR', 'TANGENT', 'BINORMAL'
13891
+ ];
13892
+ Tokenizer.registerLanguage({
13893
+ name: 'HLSL',
13894
+ extensions: ['hlsl', 'fx', 'fxh', 'vsh', 'psh'],
13895
+ lineComment: '//',
13896
+ states: {
13897
+ root: [
13898
+ { match: /\/\*/, type: 'comment', next: 'blockComment' },
13899
+ { match: /\/\/.*/, type: 'comment' },
13900
+ { match: /#\w+/, type: 'preprocessor' },
13901
+ ...NumberRules,
13902
+ { match: words(hlslKeywords), type: 'keyword' },
13903
+ { match: words(hlslBuiltins), type: 'builtin' },
13904
+ { match: words(hlslStatements), type: 'statement' },
13905
+ ...TailRules,
13906
+ ],
13907
+ ...CommonStates
13908
+ },
13909
+ reservedWords: [...hlslKeywords, ...hlslBuiltins, ...hlslStatements],
13910
+ icon: 'AlignLeft text-purple-500'
13911
+ });
13912
+ // Python
13913
+ const pyKeywords = [
13914
+ 'False', 'def', 'None', 'True', 'in', 'is', 'and', 'lambda', 'nonlocal', 'not', 'or'
13915
+ ];
13916
+ const pyStatements = [
13917
+ 'if', 'elif', 'else', 'for', 'while', 'try', 'except', 'finally', 'with', 'match', 'case',
13918
+ 'break', 'continue', 'return', 'raise', 'pass', 'import', 'from', 'as', 'global', 'nonlocal',
13919
+ 'assert', 'del', 'yield'
13920
+ ];
13921
+ const pyBuiltins = [
13922
+ 'abs', 'all', 'any', 'ascii', 'bin', 'bool', 'bytearray', 'bytes', 'callable', 'chr', 'classmethod',
13923
+ 'compile', 'complex', 'delattr', 'dict', 'dir', 'divmod', 'enumerate', 'eval', 'exec', 'filter',
13924
+ 'float', 'format', 'frozenset', 'getattr', 'globals', 'hasattr', 'hash', 'help', 'hex', 'id',
13925
+ 'input', 'int', 'isinstance', 'issubclass', 'iter', 'len', 'list', 'locals', 'map', 'max',
13926
+ 'memoryview', 'min', 'next', 'object', 'oct', 'open', 'ord', 'pow', 'print', 'property',
13927
+ 'range', 'repr', 'reversed', 'round', 'set', 'setattr', 'slice', 'sorted', 'staticmethod',
13928
+ 'str', 'sum', 'super', 'tuple', 'type', 'vars', 'zip'
13929
+ ];
13930
+ const pyTypes = [
13931
+ 'int', 'float', 'complex', 'bool', 'str', 'bytes', 'bytearray', 'list', 'tuple', 'set', 'frozenset',
13932
+ 'dict', 'object', 'type', 'ArithmeticError', 'AssertionError', 'AttributeError', 'BaseException',
13933
+ 'BufferError', 'EOFError', 'Exception', 'FloatingPointError', 'GeneratorExit', 'ImportError',
13934
+ 'ModuleNotFoundError', 'IndentationError', 'IndexError', 'KeyError', 'KeyboardInterrupt',
13935
+ 'LookupError', 'MemoryError', 'NameError', 'NotImplementedError', 'OSError', 'OverflowError',
13936
+ 'RecursionError', 'ReferenceError', 'RuntimeError', 'StopAsyncIteration', 'StopIteration',
13937
+ 'SyntaxError', 'TabError', 'SystemError', 'SystemExit', 'TypeError', 'UnboundLocalError',
13938
+ 'UnicodeError', 'UnicodeEncodeError', 'UnicodeDecodeError', 'UnicodeTranslateError',
13939
+ 'ValueError', 'ZeroDivisionError'
13940
+ ];
13941
+ Tokenizer.registerLanguage({
13942
+ name: 'Python',
13943
+ extensions: ['py'],
13944
+ lineComment: '#',
13945
+ states: {
13946
+ root: [
13947
+ { match: /\/\*/, type: 'comment', next: 'blockComment' },
13948
+ { match: /#.*/, type: 'comment' },
13949
+ { match: /"/, type: 'string', next: 'doubleString' },
13950
+ { match: /'/, type: 'string', next: 'singleString' },
13951
+ ...NumberRules,
13952
+ { match: words(pyKeywords), type: 'keyword' },
13953
+ { match: words(pyTypes), type: 'type' },
13954
+ { match: words(pyBuiltins), type: 'builtin' },
13955
+ { match: words(pyStatements), type: 'statement' },
13956
+ ...TailRules,
13957
+ ],
13958
+ ...CommonStates
13959
+ },
13960
+ reservedWords: [...pyKeywords, ...pyTypes, ...pyBuiltins, ...pyStatements],
13961
+ icon: 'Python text-cyan-600'
13962
+ });
13963
+ // PHP
13964
+ const phpKeywords = [
13965
+ 'abstract', 'and', 'array', 'as', 'callable', 'class', 'clone', 'const',
13966
+ 'enum', 'extends', 'final', 'fn', 'function', 'global',
13967
+ 'implements', 'include', 'include_once', 'instanceof',
13968
+ 'insteadof', 'interface', 'namespace', 'new', 'null', 'or',
13969
+ 'private', 'protected', 'public', 'readonly', 'require',
13970
+ 'require_once', 'static', 'trait', 'use', 'var', 'xor',
13971
+ 'from', '$this'
13972
+ ];
13973
+ const phpStatements = [
13974
+ 'if', 'else', 'elseif', 'endif', 'switch', 'case', 'default', 'endswitch',
13975
+ 'for', 'endfor', 'foreach', 'endforeach', 'while', 'endwhile', 'do',
13976
+ 'break', 'continue', 'return', 'try', 'catch', 'finally', 'throw',
13977
+ 'declare', 'enddeclare', 'goto', 'yield', 'match'
13978
+ ];
13979
+ const phpTypes = [
13980
+ 'int', 'float', 'string', 'bool', 'array', 'object', 'callable', 'iterable',
13981
+ 'void', 'never', 'mixed', 'static', 'self', 'parent',
13982
+ 'Exception', 'Error', 'Throwable', 'DateTime', 'DateTimeImmutable',
13983
+ 'Closure', 'Generator', 'JsonSerializable'
13984
+ ];
13985
+ const phpBuiltins = [
13986
+ 'echo', 'print', 'isset', 'empty', 'unset', 'eval', 'die', 'exit',
13987
+ 'count', 'sizeof', 'in_array', 'array_merge', 'array_push', 'array_pop',
13988
+ 'strlen', 'strpos', 'substr', 'str_replace', 'explode', 'implode',
13989
+ 'json_encode', 'json_decode', 'var_dump', 'print_r'
13990
+ ];
13991
+ Tokenizer.registerLanguage({
13992
+ name: 'PHP',
13993
+ extensions: ['php'],
13994
+ lineComment: '//',
13995
+ states: {
13996
+ root: [
13997
+ { match: /\/\*/, type: 'comment', next: 'blockComment' },
13998
+ { match: /\/\/.*/, type: 'comment' },
13999
+ { match: /"/, type: 'string', next: 'doubleString' },
14000
+ { match: /'/, type: 'string', next: 'singleString' },
14001
+ ...NumberRules,
14002
+ { match: words(phpKeywords), type: 'keyword' },
14003
+ { match: words(phpTypes), type: 'type' },
14004
+ { match: words(phpBuiltins), type: 'builtin' },
14005
+ { match: words(phpStatements), type: 'statement' },
14006
+ ...TailRules,
14007
+ ],
14008
+ ...CommonStates
14009
+ },
14010
+ reservedWords: [...phpKeywords, ...phpTypes, ...phpBuiltins, ...phpStatements],
14011
+ icon: 'Php text-purple-700'
14012
+ });
14013
+ // C
14014
+ const cKeywords = [
14015
+ 'int', 'float', 'double', 'bool', 'long', 'short', 'char', 'void', 'const', 'enum', 'extern', 'register', 'sizeof', 'static',
14016
+ 'struct', 'typedef', 'union', 'volatile', 'true', 'false'
14017
+ ];
14018
+ const cStatements = [
14019
+ 'break', 'continue', 'do', 'else', 'for', 'goto', 'if', 'return', 'switch', 'while'
14020
+ ];
14021
+ Tokenizer.registerLanguage({
14022
+ name: 'C',
14023
+ extensions: ['c', 'h'],
14024
+ lineComment: '//',
14025
+ states: {
14026
+ root: [
14027
+ { match: /\/\*/, type: 'comment', next: 'blockComment' },
14028
+ { match: /\/\/.*/, type: 'comment' },
14029
+ { match: /#\w+/, type: 'preprocessor' },
14030
+ { match: /"/, type: 'string', next: 'doubleString' },
14031
+ { match: /'/, type: 'string', next: 'singleString' },
14032
+ ...NumberRules,
14033
+ { match: words(cKeywords), type: 'keyword' },
14034
+ { match: words(cStatements), type: 'statement' },
14035
+ ...TailRules,
14036
+ ],
14037
+ ...CommonStates
14038
+ },
14039
+ reservedWords: [...cKeywords, ...cStatements],
14040
+ icon: { 'c': 'C text-sky-400', 'h': 'C text-fuchsia-500' }
14041
+ });
14042
+ // C++
14043
+ const cppKeywords = [
14044
+ ...cKeywords, 'wchar_t', 'static_cast', 'dynamic_cast', 'new', 'delete', 'auto', 'class', 'nullptr', 'NULL', 'signed',
14045
+ 'unsigned', 'namespace', 'static', 'private', 'public'
14046
+ ];
14047
+ const cppStatements = [
14048
+ ...cStatements, 'case', 'using', 'glm', 'spdlog', 'default'
14049
+ ];
14050
+ const cppTypes = [
14051
+ 'uint8_t', 'uint16_t', 'uint32_t', 'uint64_t', 'int8_t', 'int16_t', 'int32_t', 'int64_t', 'size_t', 'ptrdiff_t'
14052
+ ];
14053
+ const cppBuiltins = [
14054
+ 'std', 'string', 'vector', 'map', 'set', 'unordered_map', 'unordered_set', 'array', 'tuple', 'optional', 'variant',
14055
+ 'cout', 'cin', 'cerr', 'clog'
14056
+ ];
14057
+ Tokenizer.registerLanguage({
14058
+ name: 'C++',
14059
+ extensions: ['cpp', 'hpp'],
14060
+ lineComment: '//',
14061
+ states: {
14062
+ root: [
14063
+ { match: /\/\*/, type: 'comment', next: 'blockComment' },
14064
+ { match: /\/\/.*/, type: 'comment' },
14065
+ { match: /#\w+/, type: 'preprocessor' },
14066
+ { match: /"/, type: 'string', next: 'doubleString' },
14067
+ { match: /'/, type: 'string', next: 'singleString' },
14068
+ ...NumberRules,
14069
+ { match: words(cppKeywords), type: 'keyword' },
14070
+ { match: words(cppTypes), type: 'type' },
14071
+ { match: words(cppBuiltins), type: 'builtin' },
14072
+ { match: words(cppStatements), type: 'statement' },
14073
+ ...TailRules,
14074
+ ],
14075
+ ...CommonStates
14076
+ },
14077
+ reservedWords: [...cppKeywords, ...cppTypes, ...cppBuiltins, ...cppStatements],
14078
+ icon: { 'cpp': 'CPlusPlus text-sky-400', 'hpp': 'CPlusPlus text-fuchsia-500' }
14079
+ });
14080
+ // JSON
14081
+ Tokenizer.registerLanguage({
14082
+ name: 'JSON',
14083
+ extensions: ['json', 'jsonc', 'bml'],
14084
+ states: {
14085
+ root: [
14086
+ { match: /"/, type: 'string', next: 'doubleString' },
14087
+ { match: /\btrue\b|\bfalse\b|\bnull\b/, type: 'keyword' },
14088
+ { match: /-?\d+\.?\d*(?:[eE][+-]?\d+)?/, type: 'number' },
14089
+ { match: /[{}[\]:,]/, type: 'symbol' },
14090
+ { match: /\s+/, type: 'text' },
14091
+ ],
14092
+ ...CommonStates
14093
+ },
14094
+ reservedWords: [],
14095
+ icon: 'Json text-yellow-600'
14096
+ });
14097
+ // XML
14098
+ Tokenizer.registerLanguage({
14099
+ name: 'XML',
14100
+ extensions: ['xml', 'xaml', 'xsd', 'xsl'],
14101
+ lineComment: '<!--',
14102
+ states: {
14103
+ root: [
14104
+ { match: /<!--/, type: 'comment', next: 'xmlComment' },
14105
+ { match: /<\?/, type: 'preprocessor', next: 'processingInstruction' },
14106
+ { match: /<\/[a-zA-Z][\w:-]*>/, type: 'keyword' },
14107
+ { match: /<[a-zA-Z][\w:-]*/, type: 'keyword', next: 'tag' },
14108
+ { match: /[^<]+/, type: 'text' },
14109
+ ],
14110
+ tag: [
14111
+ { match: /\/?>/, type: 'keyword', pop: true },
14112
+ { match: /[a-zA-Z][\w:-]*(?==)/, type: 'type' },
14113
+ { match: /"/, type: 'string', next: 'doubleString' },
14114
+ { match: /'/, type: 'string', next: 'singleString' },
14115
+ { match: /[^"'>/]+/, type: 'text' },
14116
+ ],
14117
+ xmlComment: [
14118
+ { match: /-->/, type: 'comment', pop: true },
14119
+ { match: /[^-]+/, type: 'comment' },
14120
+ { match: /-/, type: 'comment' },
14121
+ ],
14122
+ processingInstruction: [
14123
+ { match: /\?>/, type: 'preprocessor', pop: true },
14124
+ { match: /[^?]+/, type: 'preprocessor' },
14125
+ { match: /\?/, type: 'preprocessor' },
14126
+ ],
14127
+ ...CommonStates
14128
+ },
14129
+ reservedWords: [],
14130
+ icon: 'Rss text-orange-600'
14131
+ });
14132
+ // HTML
14133
+ const htmlTags = [
14134
+ 'html', 'head', 'body', 'title', 'meta', 'link', 'script', 'style',
14135
+ 'div', 'span', 'p', 'a', 'img', 'ul', 'ol', 'li', 'table', 'tr', 'td', 'th',
14136
+ 'form', 'input', 'button', 'select', 'option', 'textarea', 'label',
14137
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'footer', 'nav', 'section', 'article',
14138
+ 'aside', 'main', 'figure', 'figcaption', 'video', 'audio', 'source', 'canvas', 'svg'
14139
+ ];
14140
+ Tokenizer.registerLanguage({
14141
+ name: 'HTML',
14142
+ extensions: ['html'],
14143
+ lineComment: '<!--',
14144
+ states: {
14145
+ root: [
14146
+ { match: /<!--/, type: 'comment', next: 'xmlComment' },
14147
+ { match: /<!DOCTYPE/i, type: 'preprocessor' },
14148
+ { match: /<\/[a-zA-Z][\w-]*>/, type: 'keyword' },
14149
+ { match: /<script\b/i, type: 'keyword', next: 'scriptTag' },
14150
+ { match: /<style\b/i, type: 'keyword', next: 'styleTag' },
14151
+ { match: /<[a-zA-Z][\w-]*/, type: 'keyword', next: 'tag' },
14152
+ { match: /[^<]+/, type: 'text' },
14153
+ ],
14154
+ tag: [
14155
+ { match: /\/?>/, type: 'keyword', pop: true },
14156
+ { match: /[a-zA-Z][\w-]*(?==)/, type: 'type' },
14157
+ { match: /"/, type: 'string', next: 'doubleString' },
14158
+ { match: /'/, type: 'string', next: 'singleString' },
14159
+ { match: /[^"'>/]+/, type: 'text' },
14160
+ ],
14161
+ scriptTag: [
14162
+ { match: /\/>/, type: 'keyword', pop: true },
14163
+ { match: />/, type: 'keyword', next: 'scriptContent' },
14164
+ { match: /[a-zA-Z][\w-]*(?==)/, type: 'type' },
14165
+ { match: /"/, type: 'string', next: 'doubleString' },
14166
+ { match: /'/, type: 'string', next: 'singleString' },
14167
+ { match: /[^"'>/]+/, type: 'text' },
14168
+ ],
14169
+ scriptContent: [
14170
+ { match: /<\/script>/i, type: 'keyword', pop: 2 },
14171
+ { match: /[^<]+/, type: 'text' },
14172
+ { match: /</, type: 'text' },
14173
+ ],
14174
+ styleTag: [
14175
+ { match: /\/>/, type: 'keyword', pop: true },
14176
+ { match: />/, type: 'keyword', next: 'styleContent' },
14177
+ { match: /[a-zA-Z][\w-]*(?==)/, type: 'type' },
14178
+ { match: /"/, type: 'string', next: 'doubleString' },
14179
+ { match: /'/, type: 'string', next: 'singleString' },
14180
+ { match: /[^"'>/]+/, type: 'text' },
14181
+ ],
14182
+ styleContent: [
14183
+ { match: /<\/style>/i, type: 'keyword', pop: 2 },
14184
+ { match: /[^<]+/, type: 'text' },
14185
+ { match: /</, type: 'text' },
14186
+ ],
14187
+ xmlComment: [
14188
+ { match: /-->/, type: 'comment', pop: true },
14189
+ { match: /[^-]+/, type: 'comment' },
14190
+ { match: /-/, type: 'comment' },
14191
+ ],
14192
+ ...CommonStates
14193
+ },
14194
+ reservedWords: [...htmlTags],
14195
+ icon: 'Code text-orange-500'
14196
+ });
14197
+ // CSS
14198
+ const cssProperties = [
14199
+ 'color', 'background', 'border', 'margin', 'padding', 'font', 'display', 'position',
14200
+ 'width', 'height', 'top', 'left', 'right', 'bottom', 'flex', 'grid', 'z-index',
14201
+ 'opacity', 'transform', 'transition', 'animation', 'content', 'visibility'
14202
+ ];
14203
+ const cssPropertyValues = [
14204
+ 'inherit', 'initial', 'unset', 'revert', 'revert-layer', 'auto', 'none', 'hidden', 'visible', 'collapse',
14205
+ 'block', 'inline', 'inline-block', 'flex', 'inline-flex', 'grid', 'inline-grid', 'contents', 'list-item',
14206
+ 'static', 'relative', 'absolute', 'fixed', 'sticky', 'solid', 'dashed', 'dotted', 'double', 'groove', 'ridge',
14207
+ 'inset', 'outset', 'bold', 'bolder', 'lighter', 'normal', 'italic', 'oblique', 'uppercase', 'lowercase', 'capitalize',
14208
+ 'left', 'right', 'center', 'top', 'bottom', 'start', 'end', 'stretch', 'space-between', 'space-around', 'space-evenly',
14209
+ 'repeat', 'no-repeat', 'repeat-x', 'repeat-y', 'cover', 'contain', 'pointer', 'default', 'move', 'text', 'not-allowed',
14210
+ 'transparent', 'currentColor'
14211
+ ];
14212
+ const cssPseudos = [
14213
+ 'hover', 'active', 'focus', 'visited', 'link', 'before', 'after', 'first-child',
14214
+ 'last-child', 'nth-child', 'not', 'root', 'disabled', 'checked'
14215
+ ];
14216
+ Tokenizer.registerLanguage({
14217
+ name: 'CSS',
14218
+ extensions: ['css', 'scss', 'sass', 'less'],
14219
+ lineComment: '//',
14220
+ states: {
14221
+ root: [
14222
+ { match: /\/\*/, type: 'comment', next: 'blockComment' },
14223
+ { match: /\/\/.*/, type: 'comment' }, // SCSS/Less
14224
+ { match: /@[\w-]+/, type: 'statement' }, // @media, @import, @keyframes, etc.
14225
+ { match: /#[\w-]+/, type: 'keyword' }, // ID selectors
14226
+ { match: /\.[\w-]+/, type: 'keyword' }, // class selectors
14227
+ { match: /::?[\w-]+(?:\([^)]*\))?/, type: 'keyword' }, // pseudo-classes/elements
14228
+ { match: /\[[\w-]+(?:[~|^$*]?=(?:"[^"]*"|'[^']*'|[\w-]+))?\]/, type: 'type' }, // attribute selectors
14229
+ { match: /{/, type: 'symbol', next: 'properties' },
14230
+ { match: /}/, type: 'symbol' },
14231
+ { match: /[,>+~*]/, type: 'symbol' }, // combinators
14232
+ { match: /[\w-]+/, type: 'keyword' }, // element selectors
14233
+ { match: /\s+/, type: 'text' },
14234
+ ],
14235
+ properties: [
14236
+ { match: /\/\*/, type: 'comment', next: 'blockComment' },
14237
+ { match: /\/\/.*/, type: 'comment' }, // SCSS/Less
14238
+ { match: /}/, type: 'symbol', pop: true },
14239
+ { match: /[\w-]+(?=\s*:)/, type: 'type' }, // property names
14240
+ { match: /:/, type: 'symbol' },
14241
+ { match: words(cssPropertyValues), type: 'string' },
14242
+ { match: /;/, type: 'symbol' },
14243
+ { match: /"/, type: 'string', next: 'doubleString' },
14244
+ { match: /'/, type: 'string', next: 'singleString' },
14245
+ { match: /#[a-fA-F0-9]{3,8}\b/, type: 'string' }, // hex colors
14246
+ { match: /-?\d+\.?\d*(?:px|em|rem|%|vh|vw|vmin|vmax|pt|cm|mm|in|pc|ex|ch|fr|deg|rad|grad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?/, type: 'number' },
14247
+ { match: /!important\b/, type: 'builtin' },
14248
+ { match: /[\w-]+\(/, type: 'method' }, // functions: rgb(), calc(), var(), url()
14249
+ { match: /--[\w-]+/, type: 'type' }, // vars --
14250
+ { match: /[(),]/, type: 'symbol' },
14251
+ { match: /{/, type: 'symbol', next: 'properties' }, // nested rules (SCSS/Less)
14252
+ { match: /[\w-]+/, type: 'text' },
14253
+ { match: /\s+/, type: 'text' },
14254
+ ],
14255
+ ...CommonStates
14256
+ },
14257
+ reservedWords: [...cssProperties, ...cssPropertyValues, ...cssPseudos],
14258
+ icon: 'Hash text-blue-500'
14259
+ });
14260
+ // Markdown
14261
+ Tokenizer.registerLanguage({
14262
+ name: 'Markdown',
14263
+ extensions: ['md', 'markdown'],
14264
+ states: {
14265
+ root: [
14266
+ { match: /^#{1,6}\s+.+/, type: 'keyword' },
14267
+ { match: /^\*\*\*.+$/, type: 'symbol' },
14268
+ { match: /\*\*[^*]+\*\*/, type: 'keyword' },
14269
+ { match: /\*[^*]+\*/, type: 'type' },
14270
+ { match: /__[^_]+__/, type: 'keyword' },
14271
+ { match: /_[^_]+_/, type: 'type' },
14272
+ { match: /`[^`]+`/, type: 'string' },
14273
+ { match: /```/, type: 'comment', next: 'codeBlock' },
14274
+ { match: /^\s*[-*+]\s+/, type: 'symbol' },
14275
+ { match: /^\s*\d+\.\s+/, type: 'symbol' },
14276
+ { match: /\[([^\]]+)\]\(([^)]+)\)/, type: 'builtin' },
14277
+ { match: /^>\s+/, type: 'comment' },
14278
+ { match: /.+/, type: 'text' },
14279
+ ],
14280
+ codeBlock: [
14281
+ { match: /```/, type: 'comment', pop: true },
14282
+ { match: /.+/, type: 'string' },
14283
+ ]
14284
+ },
14285
+ reservedWords: [],
14286
+ icon: 'Markdown text-red-500'
14287
+ });
14288
+ // Batch
14289
+ const batchKeywords = [
14290
+ 'if', 'else', 'for', 'in', 'do', 'goto', 'call', 'exit', 'setlocal', 'endlocal',
14291
+ 'set', 'echo', 'rem', 'pause', 'cd', 'pushd', 'popd', 'shift', 'start'
14292
+ ];
14293
+ const batchBuiltins = [
14294
+ 'dir', 'copy', 'move', 'del', 'ren', 'md', 'rd', 'type', 'find', 'findstr',
14295
+ 'tasklist', 'taskkill', 'ping', 'ipconfig', 'netstat', 'cls', 'title', 'color'
14296
+ ];
14297
+ Tokenizer.registerLanguage({
14298
+ name: 'Batch',
14299
+ extensions: ['bat', 'cmd'],
14300
+ lineComment: 'rem',
14301
+ states: {
14302
+ root: [
14303
+ { match: /^rem\s+.*/i, type: 'comment' },
14304
+ { match: /^::.*/, type: 'comment' },
14305
+ { match: /"/, type: 'string', next: 'doubleString' },
14306
+ { match: /%[\w]+%/, type: 'type' },
14307
+ { match: /\b\d+\b/, type: 'number' },
14308
+ { match: words([...batchKeywords, ...batchKeywords.map(w => w.toUpperCase())]), type: 'keyword' },
14309
+ { match: words(batchBuiltins), type: 'builtin' },
14310
+ { match: /@echo/, type: 'statement' },
14311
+ { match: /[a-zA-Z_]\w*/, type: 'text' },
14312
+ { match: /[<>|&()@]/, type: 'symbol' },
14313
+ { match: /\s+/, type: 'text' },
14314
+ ],
14315
+ ...CommonStates
14316
+ },
14317
+ reservedWords: [...batchKeywords, ...batchBuiltins],
14318
+ icon: 'Terminal text-gray-300'
14319
+ });
14320
+ // CMake
14321
+ const cmakeCommands = [
14322
+ 'project', 'cmake_minimum_required', 'add_executable', 'add_library', 'target_link_libraries',
14323
+ 'target_include_directories', 'set', 'option', 'if', 'else', 'elseif', 'endif', 'foreach',
14324
+ 'endforeach', 'while', 'endwhile', 'function', 'endfunction', 'macro', 'endmacro',
14325
+ 'find_package', 'include', 'message', 'install', 'add_subdirectory', 'configure_file'
14326
+ ];
14327
+ Tokenizer.registerLanguage({
14328
+ name: 'CMake',
14329
+ extensions: ['cmake', 'txt', 'cmake-cache'],
14330
+ lineComment: '#',
14331
+ states: {
14332
+ root: [
14333
+ { match: /#.*/, type: 'comment' },
14334
+ { match: /"/, type: 'string', next: 'doubleString' },
14335
+ { match: /\$\{[^}]+\}/, type: 'type' },
14336
+ { match: /\b\d+\.?\d*\b/, type: 'number' },
14337
+ { match: words([...cmakeCommands, ...cmakeCommands.map(w => w.toUpperCase())]), type: 'keyword' },
14338
+ { match: /\b[A-Z_][A-Z0-9_]*\b/, type: 'builtin' },
14339
+ { match: /[a-zA-Z_]\w*/, type: 'text' },
14340
+ { match: /[(){}]/, type: 'symbol' },
14341
+ { match: /\s+/, type: 'text' },
14342
+ ],
14343
+ ...CommonStates
14344
+ },
14345
+ reservedWords: [...cmakeCommands],
14346
+ icon: 'AlignLeft text-neutral-500'
14347
+ });
14348
+ // Rust
14349
+ const rustKeywords = [
14350
+ 'as', 'break', 'const', 'continue', 'crate', 'else', 'enum', 'extern', 'false', 'fn',
14351
+ 'for', 'if', 'impl', 'in', 'let', 'loop', 'match', 'mod', 'move', 'mut', 'pub', 'ref',
14352
+ 'return', 'self', 'Self', 'static', 'struct', 'super', 'trait', 'true', 'type', 'unsafe',
14353
+ 'use', 'where', 'while', 'async', 'await', 'dyn', 'abstract', 'become', 'box', 'do',
14354
+ 'final', 'macro', 'override', 'priv', 'typeof', 'unsized', 'virtual', 'yield'
14355
+ ];
14356
+ const rustTypes = [
14357
+ 'i8', 'i16', 'i32', 'i64', 'i128', 'isize', 'u8', 'u16', 'u32', 'u64', 'u128', 'usize',
14358
+ 'f32', 'f64', 'bool', 'char', 'str', 'String', 'Vec', 'Option', 'Result', 'Box'
14359
+ ];
14360
+ const rustBuiltins = [
14361
+ 'println', 'print', 'eprintln', 'eprint', 'format', 'panic', 'assert', 'assert_eq',
14362
+ 'assert_ne', 'debug_assert', 'vec', 'Some', 'None', 'Ok', 'Err'
14363
+ ];
14364
+ Tokenizer.registerLanguage({
14365
+ name: 'Rust',
14366
+ extensions: ['rs'],
14367
+ lineComment: '//',
14368
+ states: {
14369
+ root: [
14370
+ { match: /\/\*/, type: 'comment', next: 'blockComment' },
14371
+ { match: /\/\/.*/, type: 'comment' },
14372
+ { match: /"/, type: 'string', next: 'doubleString' },
14373
+ { match: /'(?:\\.|[^'])+'/, type: 'string' },
14374
+ { match: /r#*"/, type: 'string', next: 'rawString' },
14375
+ ...NumberRules,
14376
+ { match: /#\[[\w:]+\]/, type: 'preprocessor' },
14377
+ { match: words(rustKeywords), type: 'keyword' },
14378
+ { match: words(rustTypes), type: 'type' },
14379
+ { match: words(rustBuiltins), type: 'builtin' },
14380
+ { match: /![a-zA-Z_]\w*/, type: 'preprocessor' },
14381
+ ...TailRules,
14382
+ ],
14383
+ rawString: [
14384
+ { match: /"#*/, type: 'string', pop: true },
14385
+ { match: /[^"]+/, type: 'string' },
14386
+ { match: /"/, type: 'string' },
14387
+ ],
14388
+ ...CommonStates
14389
+ },
14390
+ reservedWords: [...rustKeywords, ...rustTypes, ...rustBuiltins],
14391
+ icon: 'Rust text-orange-400'
14392
+ });
14393
+ // ____ _
14394
+ // | \ ___ ___ _ _ _____ ___ ___| |_
14395
+ // | | | . | _| | | | -_| | _|
14396
+ // |____/|___|___|___|_|_|_|___|_|_|_|
14397
+ class CodeDocument {
14398
+ onChange = undefined;
14399
+ _lines = [''];
14400
+ get lineCount() {
14401
+ return this._lines.length;
14402
+ }
14403
+ constructor(onChange) {
14404
+ this.onChange = onChange;
14405
+ }
14406
+ getLine(n) {
14407
+ return this._lines[n] ?? '';
14408
+ }
14409
+ getText(separator = '\n') {
14410
+ return this._lines.join(separator);
14411
+ }
14412
+ setText(text) {
14413
+ this._lines = text.split(/\r?\n/);
14414
+ if (this._lines.length === 0) {
14415
+ this._lines = [''];
14416
+ }
14417
+ if (this.onChange)
14418
+ this.onChange(this);
14419
+ }
14420
+ getCharAt(line, col) {
14421
+ const l = this._lines[line];
14422
+ if (!l || col < 0 || col >= l.length)
14423
+ return undefined;
14424
+ return l[col];
14425
+ }
14426
+ /**
14427
+ * Get the word at a given position. Returns [word, startCol, endCol].
14428
+ */
14429
+ getWordAt(line, col) {
14430
+ const l = this._lines[line];
14431
+ if (!l || col < 0 || col > l.length)
14432
+ return ['', col, col];
14433
+ // Expand left
14434
+ let start = col;
14435
+ while (start > 0 && isWord(l[start - 1])) {
14436
+ start--;
14437
+ }
14438
+ // Expand right
14439
+ let end = col;
14440
+ while (end < l.length && isWord(l[end])) {
14441
+ end++;
14442
+ }
14443
+ return [l.substring(start, end), start, end];
14444
+ }
14445
+ /**
14446
+ * Get the indentation level (number of leading spaces) of a line.
14447
+ */
14448
+ getIndent(line) {
14449
+ const idx = firstNonspaceIndex(this._lines[line] ?? '');
14450
+ return idx === -1 ? (this._lines[line]?.length ?? 0) : idx;
14451
+ }
14452
+ /**
14453
+ * Find the next occurrence of 'text' starting after (line, col). Returns null if no match.
14454
+ */
14455
+ findNext(text, startLine, startCol) {
14456
+ if (!text)
14457
+ return null;
14458
+ // Search from startLine:startCol forward
14459
+ for (let i = startLine; i < this._lines.length; i++) {
14460
+ const searchFrom = i === startLine ? startCol : 0;
14461
+ const idx = this._lines[i].indexOf(text, searchFrom);
14462
+ if (idx !== -1)
14463
+ return { line: i, col: idx };
14464
+ }
14465
+ // Wrap around from beginning
14466
+ for (let i = 0; i <= startLine; i++) {
14467
+ const searchUntil = i === startLine ? startCol : this._lines[i].length;
14468
+ const idx = this._lines[i].indexOf(text);
14469
+ if (idx !== -1 && idx < searchUntil)
14470
+ return { line: i, col: idx };
14471
+ }
14472
+ return null;
14473
+ }
14474
+ // Mutations (return EditOperations for undo):
14475
+ /**
14476
+ * Insert text at a position (handles newlines in the inserted text).
14477
+ */
14478
+ insert(line, col, text) {
14479
+ const parts = text.split(/\r?\n/);
14480
+ const currentLine = this._lines[line] ?? '';
14481
+ const before = currentLine.substring(0, col);
14482
+ const after = currentLine.substring(col);
14483
+ if (parts.length === 1) {
14484
+ this._lines[line] = before + parts[0] + after;
14485
+ }
14486
+ else {
14487
+ this._lines[line] = before + parts[0];
14488
+ for (let i = 1; i < parts.length - 1; i++) {
14489
+ this._lines.splice(line + i, 0, parts[i]);
14490
+ }
14491
+ this._lines.splice(line + parts.length - 1, 0, parts[parts.length - 1] + after);
14492
+ }
14493
+ if (this.onChange)
14494
+ this.onChange(this);
14495
+ return { type: 'insert', line, col, text };
14496
+ }
14497
+ /**
14498
+ * Delete `length` characters forward from a position (handles crossing line boundaries).
14499
+ */
14500
+ delete(line, col, length) {
14501
+ let remaining = length;
14502
+ let deletedText = '';
14503
+ let currentLine = line;
14504
+ let currentCol = col;
14505
+ while (remaining > 0 && currentLine < this._lines.length) {
14506
+ const l = this._lines[currentLine];
14507
+ const available = l.length - currentCol;
14508
+ if (remaining <= available) {
14509
+ deletedText += l.substring(currentCol, currentCol + remaining);
14510
+ this._lines[currentLine] = l.substring(0, currentCol) + l.substring(currentCol + remaining);
14511
+ remaining = 0;
14512
+ }
14513
+ else {
14514
+ // Delete rest of this line + the newline
14515
+ deletedText += l.substring(currentCol) + '\n';
14516
+ remaining -= (available + 1); // +1 for the newline
14517
+ // Merge next line into current
14518
+ const nextContent = this._lines[currentLine + 1] ?? '';
14519
+ this._lines[currentLine] = l.substring(0, currentCol) + nextContent;
14520
+ this._lines.splice(currentLine + 1, 1);
14521
+ }
14522
+ }
14523
+ if (this.onChange)
14524
+ this.onChange(this);
14525
+ return { type: 'delete', line, col, text: deletedText };
14526
+ }
14527
+ /**
14528
+ * Insert a new line after 'afterLine'. If afterLine is -1, inserts at the beginning.
14529
+ */
14530
+ insertLine(afterLine, text = '') {
14531
+ const insertAt = afterLine + 1;
14532
+ this._lines.splice(insertAt, 0, text);
14533
+ if (this.onChange)
14534
+ this.onChange(this);
14535
+ return { type: 'insert', line: Math.max(afterLine, 0), col: afterLine >= 0 ? this._lines[afterLine]?.length ?? 0 : 0, text: '\n' + text };
14536
+ }
14537
+ /**
14538
+ * Remove an entire line and its trailing newline.
14539
+ */
14540
+ removeLine(line) {
14541
+ const text = this._lines[line];
14542
+ this._lines.splice(line, 1);
14543
+ if (this._lines.length === 0) {
14544
+ this._lines = [''];
14545
+ }
14546
+ if (this.onChange)
14547
+ this.onChange(this);
14548
+ return { type: 'delete', line: Math.max(line - 1, 0), col: line > 0 ? (this._lines[line - 1]?.length ?? 0) : 0, text: '\n' + text };
14549
+ }
14550
+ replaceLine(line, newText) {
14551
+ const oldText = this._lines[line];
14552
+ this._lines[line] = newText;
14553
+ if (this.onChange)
14554
+ this.onChange(this);
14555
+ return { type: 'replaceLine', line, col: 0, text: newText, oldText };
14556
+ }
14557
+ /**
14558
+ * Apply an edit operation (used by undo/redo).
14559
+ */
14560
+ applyInverse(op) {
14561
+ if (op.type === 'insert') {
14562
+ return this.delete(op.line, op.col, op.text.length);
14563
+ }
14564
+ else if (op.type === 'replaceLine') {
14565
+ return this.replaceLine(op.line, op.oldText);
14566
+ }
14567
+ else {
14568
+ return this.insert(op.line, op.col, op.text);
14569
+ }
14570
+ }
14571
+ }
14572
+ class UndoManager {
14573
+ _undoStack = [];
14574
+ _redoStack = [];
14575
+ _pendingOps = [];
14576
+ _pendingCursorsBefore = [];
14577
+ _pendingCursorsAfter = [];
14578
+ _lastPushTime = 0;
14579
+ _groupThresholdMs;
14580
+ _maxSteps;
14581
+ constructor(groupThresholdMs = 2000, maxSteps = 200) {
14582
+ this._groupThresholdMs = groupThresholdMs;
14583
+ this._maxSteps = maxSteps;
14584
+ }
14585
+ /**
14586
+ * Record an edit operation. Consecutive operations within the time threshold
14587
+ * are grouped into a single undo step.
14588
+ */
14589
+ record(op, cursors) {
14590
+ const now = Date.now();
14591
+ const elapsed = now - this._lastPushTime;
14592
+ if (elapsed > this._groupThresholdMs && this._pendingOps.length > 0) {
14593
+ this._flush();
14594
+ }
14595
+ if (this._pendingOps.length === 0) {
14596
+ this._pendingCursorsBefore = cursors.map(c => ({ ...c }));
14597
+ }
14598
+ this._pendingOps.push(op);
14599
+ this._pendingCursorsAfter = cursors.map(c => ({ ...c }));
14600
+ this._lastPushTime = now;
14601
+ // New edits clear the redo stack
14602
+ this._redoStack.length = 0;
14603
+ }
14604
+ /**
14605
+ * Force-flush pending operations into a final undo step.
14606
+ */
14607
+ flush(cursorsAfter) {
14608
+ if (cursorsAfter && this._pendingOps.length > 0) {
14609
+ this._pendingCursorsAfter = cursorsAfter.map(c => ({ ...c }));
14610
+ }
14611
+ this._flush();
14612
+ }
14613
+ /**
14614
+ * Undo the last step. Stores the operations to apply inversely, and returns the cursor state to restore.
14615
+ */
14616
+ undo(doc, currentCursors) {
14617
+ this.flush(currentCursors);
14618
+ const entry = this._undoStack.pop();
14619
+ if (!entry)
14620
+ return null;
14621
+ // Apply in reverse order
14622
+ const redoOps = [];
14623
+ for (let i = entry.operations.length - 1; i >= 0; i--) {
14624
+ const inverseOp = doc.applyInverse(entry.operations[i]);
14625
+ redoOps.unshift(inverseOp);
14626
+ }
14627
+ this._redoStack.push({ operations: redoOps, cursorsBefore: entry.cursorsBefore, cursorsAfter: entry.cursorsAfter });
14628
+ return { cursors: entry.cursorsBefore };
14629
+ }
14630
+ /**
14631
+ * Redo the last undone step. Returns the cursor state to restore.
14632
+ */
14633
+ redo(doc) {
14634
+ const entry = this._redoStack.pop();
14635
+ if (!entry)
14636
+ return null;
14637
+ // Apply in forward order (redo entries are already in correct order)
14638
+ const undoOps = [];
14639
+ for (let i = 0; i < entry.operations.length; i++) {
14640
+ const inverseOp = doc.applyInverse(entry.operations[i]);
14641
+ undoOps.push(inverseOp);
14642
+ }
14643
+ this._undoStack.push({ operations: undoOps, cursorsBefore: entry.cursorsBefore, cursorsAfter: entry.cursorsAfter });
14644
+ return { cursors: entry.cursorsAfter };
14645
+ }
14646
+ canUndo() {
14647
+ return this._undoStack.length > 0 || this._pendingOps.length > 0;
14648
+ }
14649
+ canRedo() {
14650
+ return this._redoStack.length > 0;
14651
+ }
14652
+ clear() {
14653
+ this._undoStack.length = 0;
14654
+ this._redoStack.length = 0;
14655
+ this._pendingOps.length = 0;
14656
+ this._lastPushTime = 0;
14657
+ }
14658
+ _flush() {
14659
+ if (this._pendingOps.length === 0)
14660
+ return;
14661
+ this._undoStack.push({
14662
+ operations: [...this._pendingOps],
14663
+ cursorsBefore: [...this._pendingCursorsBefore],
14664
+ cursorsAfter: [...this._pendingCursorsAfter]
14665
+ });
14666
+ this._pendingOps.length = 0;
14667
+ this._pendingCursorsBefore = [];
14668
+ this._pendingCursorsAfter = [];
14669
+ // Limit stack size
14670
+ while (this._undoStack.length > this._maxSteps) {
14671
+ this._undoStack.shift();
14672
+ }
14673
+ }
14674
+ }
14675
+ function posEqual(a, b) {
14676
+ return a.line === b.line && a.col === b.col;
14677
+ }
14678
+ function posBefore(a, b) {
14679
+ return a.line < b.line || (a.line === b.line && a.col < b.col);
14680
+ }
14681
+ function selectionStart(sel) {
14682
+ return posBefore(sel.anchor, sel.head) ? sel.anchor : sel.head;
14683
+ }
14684
+ function selectionEnd(sel) {
14685
+ return posBefore(sel.anchor, sel.head) ? sel.head : sel.anchor;
14686
+ }
14687
+ function selectionIsEmpty(sel) {
14688
+ return posEqual(sel.anchor, sel.head);
14689
+ }
14690
+ class CursorSet {
14691
+ cursors = [];
14692
+ constructor() {
14693
+ // Start with one cursor at 0,0
14694
+ this.cursors = [{ anchor: { line: 0, col: 0 }, head: { line: 0, col: 0 } }];
14695
+ }
14696
+ getPrimary() {
14697
+ return this.cursors[0];
14698
+ }
14699
+ /**
14700
+ * Set a single cursor position. Clears all secondary cursors and selections.
14701
+ */
14702
+ set(line, col) {
14703
+ this.cursors = [{ anchor: { line, col }, head: { line, col } }];
14704
+ }
14705
+ // Movement:
14706
+ moveLeft(doc, selecting = false) {
14707
+ for (const sel of this.cursors) {
14708
+ this._moveHead(sel, doc, -1, 0, selecting);
14709
+ }
14710
+ this._merge();
14711
+ }
14712
+ moveRight(doc, selecting = false) {
14713
+ for (const sel of this.cursors) {
14714
+ this._moveHead(sel, doc, 1, 0, selecting);
14715
+ }
14716
+ this._merge();
14717
+ }
14718
+ moveUp(doc, selecting = false) {
14719
+ for (const sel of this.cursors) {
14720
+ this._moveVertical(sel, doc, -1, selecting);
14721
+ }
14722
+ this._merge();
14723
+ }
14724
+ moveDown(doc, selecting = false) {
14725
+ for (const sel of this.cursors) {
14726
+ this._moveVertical(sel, doc, 1, selecting);
14727
+ }
14728
+ this._merge();
14729
+ }
14730
+ moveToLineStart(doc, selecting = false, noIndent = false) {
14731
+ for (const sel of this.cursors) {
14732
+ const line = sel.head.line;
14733
+ const indent = doc.getIndent(line);
14734
+ // Toggle between indent and column 0
14735
+ const targetCol = (sel.head.col === indent || noIndent) ? 0 : indent;
14736
+ sel.head = { line, col: targetCol };
14737
+ if (!selecting)
14738
+ sel.anchor = { ...sel.head };
14739
+ }
14740
+ this._merge();
14741
+ }
14742
+ moveToLineEnd(doc, selecting = false) {
14743
+ for (const sel of this.cursors) {
14744
+ const line = sel.head.line;
14745
+ sel.head = { line, col: doc.getLine(line).length };
14746
+ if (!selecting)
14747
+ sel.anchor = { ...sel.head };
14748
+ }
14749
+ this._merge();
14750
+ }
14751
+ moveWordLeft(doc, selecting = false) {
14752
+ for (const sel of this.cursors) {
14753
+ const { line, col } = sel.head;
14754
+ if (col === 0 && line > 0) {
14755
+ // Jump to end of previous line
14756
+ sel.head = { line: line - 1, col: doc.getLine(line - 1).length };
14757
+ }
14758
+ else {
14759
+ const l = doc.getLine(line);
14760
+ let c = col;
14761
+ // Skip whitespace
14762
+ while (c > 0 && /\s/.test(l[c - 1]))
14763
+ c--;
14764
+ // Skip word or symbols
14765
+ if (c > 0 && isWord(l[c - 1])) {
14766
+ while (c > 0 && isWord(l[c - 1]))
14767
+ c--;
14768
+ }
14769
+ else if (c > 0) {
14770
+ while (c > 0 && isSymbol(l[c - 1]))
14771
+ c--;
14772
+ }
14773
+ sel.head = { line, col: c };
14774
+ }
14775
+ if (!selecting)
14776
+ sel.anchor = { ...sel.head };
14777
+ }
14778
+ this._merge();
14779
+ }
14780
+ moveWordRight(doc, selecting = false) {
14781
+ for (const sel of this.cursors) {
14782
+ const { line, col } = sel.head;
14783
+ const l = doc.getLine(line);
14784
+ if (col >= l.length && line < doc.lineCount - 1) {
14785
+ // Jump to start of next line
14786
+ sel.head = { line: line + 1, col: 0 };
14787
+ }
14788
+ else {
14789
+ let c = col;
14790
+ // Skip leading whitespace first
14791
+ while (c < l.length && /\s/.test(l[c]))
14792
+ c++;
14793
+ // Then skip word or symbols to end of group
14794
+ if (c < l.length && isWord(l[c])) {
14795
+ while (c < l.length && isWord(l[c]))
14796
+ c++;
14797
+ }
14798
+ else if (c < l.length && isSymbol(l[c])) {
14799
+ while (c < l.length && isSymbol(l[c]))
14800
+ c++;
14801
+ }
14802
+ sel.head = { line, col: c };
14803
+ }
14804
+ if (!selecting)
14805
+ sel.anchor = { ...sel.head };
14806
+ }
14807
+ this._merge();
14808
+ }
14809
+ selectAll(doc) {
14810
+ const lastLine = doc.lineCount - 1;
14811
+ this.cursors = [{
14812
+ anchor: { line: 0, col: 0 },
14813
+ head: { line: lastLine, col: doc.getLine(lastLine).length }
14814
+ }];
14815
+ }
14816
+ // Multi-cursor:
14817
+ addCursor(line, col) {
14818
+ this.cursors.push({ anchor: { line, col }, head: { line, col } });
14819
+ this._merge();
14820
+ }
14821
+ removeSecondaryCursors() {
14822
+ this.cursors = [this.cursors[0]];
14823
+ }
14824
+ /**
14825
+ * Returns cursor indices sorted by head position, bottom-to-top (last in doc first)
14826
+ * to ensure earlier cursors' positions stay valid.
14827
+ */
14828
+ sortedIndicesBottomUp() {
14829
+ return this.cursors
14830
+ .map((_, i) => i)
14831
+ .sort((a, b) => {
14832
+ const ah = this.cursors[a].head;
14833
+ const bh = this.cursors[b].head;
14834
+ return bh.line !== ah.line ? bh.line - ah.line : bh.col - ah.col;
14835
+ });
14836
+ }
14837
+ /**
14838
+ * After editing at (line, col), shift all other cursors on the same line
14839
+ * that are at or after `afterCol` by `colDelta`. Also handles line shifts
14840
+ * for multi-line inserts/deletes via `lineDelta`.
14841
+ */
14842
+ adjustOthers(skipIdx, line, afterCol, colDelta, lineDelta = 0) {
14843
+ for (let i = 0; i < this.cursors.length; i++) {
14844
+ if (i === skipIdx)
14845
+ continue;
14846
+ const c = this.cursors[i];
14847
+ // Adjust head
14848
+ if (lineDelta !== 0 && c.head.line > line) {
14849
+ c.head = { line: c.head.line + lineDelta, col: c.head.col };
14850
+ }
14851
+ else if (c.head.line === line && c.head.col >= afterCol) {
14852
+ c.head = { line: c.head.line + lineDelta, col: c.head.col + colDelta };
14853
+ }
14854
+ // Adjust anchor
14855
+ if (lineDelta !== 0 && c.anchor.line > line) {
14856
+ c.anchor = { line: c.anchor.line + lineDelta, col: c.anchor.col };
14857
+ }
14858
+ else if (c.anchor.line === line && c.anchor.col >= afterCol) {
14859
+ c.anchor = { line: c.anchor.line + lineDelta, col: c.anchor.col + colDelta };
14860
+ }
14861
+ }
14862
+ }
14863
+ // Queries:
14864
+ hasSelection(index = 0) {
14865
+ const sel = this.cursors[index];
14866
+ return sel ? !posEqual(sel.anchor, sel.head) : false;
14867
+ }
14868
+ getSelectedText(doc, index = 0) {
14869
+ const sel = this.cursors[index];
14870
+ if (!sel || selectionIsEmpty(sel))
14871
+ return '';
14872
+ const start = selectionStart(sel);
14873
+ const end = selectionEnd(sel);
14874
+ if (start.line === end.line) {
14875
+ return doc.getLine(start.line).substring(start.col, end.col);
14876
+ }
14877
+ const lines = [];
14878
+ lines.push(doc.getLine(start.line).substring(start.col));
14879
+ for (let i = start.line + 1; i < end.line; i++) {
14880
+ lines.push(doc.getLine(i));
14881
+ }
14882
+ lines.push(doc.getLine(end.line).substring(0, end.col));
14883
+ return lines.join('\n');
14884
+ }
14885
+ getCursorPositions() {
14886
+ return this.cursors.map(s => ({ ...s.head }));
14887
+ }
14888
+ _moveHead(sel, doc, dx, _dy, selecting) {
14889
+ const { line, col } = sel.head;
14890
+ // If there's a selection and we're not extending it, collapse to the edge
14891
+ if (!selecting && !selectionIsEmpty(sel)) {
14892
+ const target = dx < 0 ? selectionStart(sel) : selectionEnd(sel);
14893
+ sel.head = { ...target };
14894
+ sel.anchor = { ...target };
14895
+ return;
14896
+ }
14897
+ if (dx < 0) {
14898
+ if (col > 0) {
14899
+ sel.head = { line, col: col - 1 };
14900
+ }
14901
+ else if (line > 0) {
14902
+ sel.head = { line: line - 1, col: doc.getLine(line - 1).length };
14903
+ }
14904
+ }
14905
+ else if (dx > 0) {
14906
+ const lineLen = doc.getLine(line).length;
14907
+ if (col < lineLen) {
14908
+ sel.head = { line, col: col + 1 };
14909
+ }
14910
+ else if (line < doc.lineCount - 1) {
14911
+ sel.head = { line: line + 1, col: 0 };
14912
+ }
14913
+ }
14914
+ if (!selecting)
14915
+ sel.anchor = { ...sel.head };
14916
+ }
14917
+ _moveVertical(sel, doc, direction, selecting) {
14918
+ const { line, col } = sel.head;
14919
+ const newLine = line + direction;
14920
+ if (newLine < 0 || newLine >= doc.lineCount)
14921
+ return;
14922
+ const newLineLen = doc.getLine(newLine).length;
14923
+ sel.head = { line: newLine, col: Math.min(col, newLineLen) };
14924
+ if (!selecting)
14925
+ sel.anchor = { ...sel.head };
14926
+ }
14927
+ /**
14928
+ * Merge overlapping cursors/selections. Keeps the first one when duplicates exist.
14929
+ */
14930
+ _merge() {
14931
+ if (this.cursors.length <= 1)
14932
+ return;
14933
+ // Sort by head position
14934
+ this.cursors.sort((a, b) => {
14935
+ if (a.head.line !== b.head.line)
14936
+ return a.head.line - b.head.line;
14937
+ return a.head.col - b.head.col;
14938
+ });
14939
+ const merged = [this.cursors[0]];
14940
+ for (let i = 1; i < this.cursors.length; i++) {
14941
+ const prev = merged[merged.length - 1];
14942
+ const curr = this.cursors[i];
14943
+ if (posEqual(prev.head, curr.head)) {
14944
+ // Same position — skip duplicate
14945
+ continue;
14946
+ }
14947
+ merged.push(curr);
14948
+ }
14949
+ this.cursors = merged;
14950
+ }
14951
+ }
14952
+ /**
14953
+ * Manages code symbols for autocomplete, navigation, and outlining.
14954
+ * Incrementally updates as lines change.
14955
+ */
14956
+ class SymbolTable {
14957
+ _symbols = new Map(); // name -> symbols[]
14958
+ _lineSymbols = []; // [lineNum] -> symbols declared on that line
14959
+ _scopeStack = [{ name: 'global', type: 'global', line: 0 }];
14960
+ _lineScopes = []; // [lineNum] -> scope stack at that line
14961
+ get currentScope() {
14962
+ return this._scopeStack[this._scopeStack.length - 1]?.name ?? 'global';
14963
+ }
14964
+ get currentScopeType() {
14965
+ return this._scopeStack[this._scopeStack.length - 1]?.type ?? 'global';
14966
+ }
14967
+ getScopeAtLine(line) {
14968
+ return this._lineScopes[line] ?? [{ name: 'global', type: 'global', line: 0 }];
14969
+ }
14970
+ getSymbols(name) {
14971
+ return this._symbols.get(name) ?? [];
14972
+ }
14973
+ getAllSymbolNames() {
14974
+ return Array.from(this._symbols.keys());
14975
+ }
14976
+ getAllSymbols() {
14977
+ const all = [];
14978
+ for (const symbols of this._symbols.values()) {
14979
+ all.push(...symbols);
14980
+ }
14981
+ return all;
14982
+ }
14983
+ getLineSymbols(line) {
14984
+ return this._lineSymbols[line] ?? [];
14985
+ }
14986
+ /** Update scope stack for a line (call before parsing symbols) */
14987
+ updateScopeForLine(line, lineText) {
14988
+ // Track braces to maintain scope stack
14989
+ const openBraces = (lineText.match(/\{/g) || []).length;
14990
+ const closeBraces = (lineText.match(/\}/g) || []).length;
14991
+ // Save current scope for this line
14992
+ this._lineScopes[line] = [...this._scopeStack];
14993
+ // Pop scopes for closing braces
14994
+ for (let i = 0; i < closeBraces; i++) {
14995
+ if (this._scopeStack.length > 1)
14996
+ this._scopeStack.pop();
14997
+ }
14998
+ // Push scopes for opening braces (will be named by symbol detection)
14999
+ for (let i = 0; i < openBraces; i++) {
15000
+ this._scopeStack.push({ name: 'anonymous', type: 'anonymous', line });
15001
+ }
15002
+ }
15003
+ /** Name the most recent anonymous scope (called when detecting class/function) */
15004
+ nameCurrentScope(name, type) {
15005
+ if (this._scopeStack.length > 0) {
15006
+ const current = this._scopeStack[this._scopeStack.length - 1];
15007
+ if (current.name === 'anonymous' || current.type === 'anonymous') {
15008
+ current.name = name;
15009
+ current.type = type;
15010
+ }
15011
+ }
15012
+ }
15013
+ /** Remove symbols from a line (before reparsing) */
15014
+ removeLineSymbols(line) {
15015
+ const oldSymbols = this._lineSymbols[line];
15016
+ if (!oldSymbols)
15017
+ return;
15018
+ for (const symbol of oldSymbols) {
15019
+ const list = this._symbols.get(symbol.name);
15020
+ if (!list)
15021
+ continue;
15022
+ // Remove this specific symbol from the name's list
15023
+ const index = list.findIndex(s => s.line === line && s.kind === symbol.kind && s.scope === symbol.scope);
15024
+ if (index !== -1)
15025
+ list.splice(index, 1);
15026
+ if (list.length === 0)
15027
+ this._symbols.delete(symbol.name);
15028
+ }
15029
+ this._lineSymbols[line] = [];
15030
+ }
15031
+ addSymbol(symbol) {
15032
+ if (!this._symbols.has(symbol.name)) {
15033
+ this._symbols.set(symbol.name, []);
15034
+ }
15035
+ this._symbols.get(symbol.name).push(symbol);
15036
+ if (!this._lineSymbols[symbol.line]) {
15037
+ this._lineSymbols[symbol.line] = [];
15038
+ }
15039
+ this._lineSymbols[symbol.line].push(symbol);
15040
+ }
15041
+ /** Reset scope stack (e.g. when document structure changes significantly) */
15042
+ resetScopes() {
15043
+ this._scopeStack = [{ name: 'global', type: 'global', line: 0 }];
15044
+ this._lineScopes = [];
15045
+ }
15046
+ clear() {
15047
+ this._symbols.clear();
15048
+ this._lineSymbols = [];
15049
+ this.resetScopes();
15050
+ }
15051
+ }
15052
+ /**
15053
+ * Parse symbols from a line of code using token types and text patterns to detect declarations.
15054
+ */
15055
+ function parseSymbolsFromLine(lineText, tokens, line, symbolTable) {
15056
+ const symbols = [];
15057
+ const scope = symbolTable.currentScope;
15058
+ const scopeType = symbolTable.currentScopeType;
15059
+ // Build set of reserved words from tokens (keywords, statements, builtins) to skip when detecting symbols
15060
+ const reservedWords = new Set();
15061
+ for (const token of tokens) {
15062
+ if (['keyword', 'statement', 'builtin', 'preprocessor'].includes(token.type)) {
15063
+ reservedWords.add(token.value);
15064
+ }
15065
+ }
15066
+ // Track added symbols by name and approximate position to avoid duplicates
15067
+ const addedSymbols = new Set();
15068
+ const addSymbol = (name, kind, col = 0) => {
15069
+ if (!name || !name.match(/^[a-zA-Z_$][\w$]*$/))
15070
+ return; // Valid identifier check
15071
+ if (reservedWords.has(name))
15072
+ return;
15073
+ // Unique key using 5 chars tolerance
15074
+ const posKey = `${name}@${Math.floor(col / 5) * 5}`;
15075
+ if (addedSymbols.has(posKey))
15076
+ return; // Already added
15077
+ symbols.push({ name, kind, scope, line, col });
15078
+ addedSymbols.add(posKey);
15079
+ };
15080
+ // Top-level declaration patterns (only one per line, checked with anchors)
15081
+ const topLevelPatterns = [
15082
+ { regex: /^\s*class\s+([A-Z_]\w*)/i, kind: 'class' },
15083
+ { regex: /^\s*struct\s+([A-Z_]\w*)/i, kind: 'struct' },
15084
+ { regex: /^\s*interface\s+([A-Z_]\w*)/i, kind: 'interface' },
15085
+ { regex: /^\s*enum\s+([A-Z_]\w*)/i, kind: 'enum' },
15086
+ { regex: /^\s*type\s+([A-Z_]\w*)\s*=/i, kind: 'type' },
15087
+ { regex: /^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)/i, kind: 'function' },
15088
+ { regex: /^\s*(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>/i, kind: 'function' },
15089
+ { regex: /^\s*(?:static\s+|const\s+|virtual\s+|inline\s+|extern\s+|pub\s+|async\s+)*(\w+[\w\s\*&:<>,]*?)\s+(\w+)\s*\([^)]*\)\s*[{;]/i, kind: 'typed-function' },
15090
+ { regex: /^\s*(?:public|private|protected|static|readonly)*\s*(\w+)\s*\([^)]*\)\s*[:{]/i, kind: scopeType === 'class' ? 'method' : 'function' }
15091
+ ];
15092
+ const multiPatterns = [
15093
+ { regex: /(?:const|let|var)\s+(\w+)/gi, kind: 'variable' },
15094
+ { regex: /(\w+)\s*:\s*(?:function|[A-Z]\w*)/gi, kind: 'variable' },
15095
+ { regex: /this\.(\w+)\s*=/gi, kind: 'property' },
15096
+ { regex: /new\s+([A-Z]\w+)/gi, kind: 'constructor-call' },
15097
+ { regex: /(\w+)\s*\(/gi, kind: 'method-call' }
15098
+ ];
15099
+ let foundTopLevel = false;
15100
+ for (const pattern of topLevelPatterns) {
15101
+ const match = lineText.match(pattern.regex);
15102
+ if (match) {
15103
+ let name;
15104
+ let kind = pattern.kind;
15105
+ if (pattern.kind === 'typed-function' && match[2]) {
15106
+ name = match[2];
15107
+ kind = scopeType === 'class' ? 'method' : 'function';
15108
+ }
15109
+ else if (match[1]) {
15110
+ name = match[1];
15111
+ }
15112
+ else {
15113
+ continue;
15114
+ }
15115
+ const col = lineText.indexOf(name);
15116
+ addSymbol(name, kind, col);
15117
+ if (['class', 'function', 'method', 'enum', 'struct', 'interface'].includes(kind)) {
15118
+ symbolTable.nameCurrentScope(name, kind);
15119
+ }
15120
+ foundTopLevel = true;
15121
+ break;
15122
+ }
15123
+ }
15124
+ // If no top-level declaration, check for multiple occurrences
15125
+ if (!foundTopLevel) {
15126
+ for (const pattern of multiPatterns) {
15127
+ const matches = lineText.matchAll(pattern.regex);
15128
+ for (const match of matches) {
15129
+ if (match[1]) {
15130
+ const name = match[1];
15131
+ const col = match.index ?? 0;
15132
+ addSymbol(name, pattern.kind, col);
15133
+ }
15134
+ }
15135
+ }
15136
+ }
15137
+ if (scopeType === 'enum') {
15138
+ const enumValueMatch = lineText.match(/^\s*(\w+)\s*[,=]?/);
15139
+ if (enumValueMatch && enumValueMatch[1] && !lineText.includes('enum')) {
15140
+ addSymbol(enumValueMatch[1], 'enum-value', lineText.indexOf(enumValueMatch[1]));
15141
+ }
15142
+ }
15143
+ return symbols;
15144
+ }
15145
+ // _____ _ _____ _ _ _ ____ _____ _____
15146
+ // | |___ _| |___| __|_| |_| |_ ___ ___ | \| | |
15147
+ // | --| . | . | -_| __| . | | _| . | _| | | | | | | | |
15148
+ // |_____|___|___|___|_____|___|_|_| |___|_| |____/|_____|_|_|_|
15149
+ const g = globalThis;
15150
+ const TOKEN_CLASS_MAP = {
15151
+ 'keyword': 'cm-kwd',
15152
+ 'statement': 'cm-std',
15153
+ 'type': 'cm-typ',
15154
+ 'builtin': 'cm-bln',
15155
+ 'string': 'cm-str',
15156
+ 'comment': 'cm-com',
15157
+ 'number': 'cm-dec',
15158
+ 'method': 'cm-mtd',
15159
+ 'symbol': 'cm-sym',
15160
+ 'enum': 'cm-enu',
15161
+ 'preprocessor': 'cm-ppc',
15162
+ 'variable': 'cm-var',
15163
+ };
15164
+ LX.Area;
15165
+ LX.Panel;
15166
+ LX.Tabs;
15167
+ LX.NodeTree;
15168
+ // _____ _ _ _____
15169
+ // | __|___ ___ ___| | | __ |___ ___
15170
+ // |__ | _| _| . | | | __ -| .'| _|
15171
+ // |_____|___|_| |___|_|_|_____|__,|_|
15172
+ class ScrollBar {
15173
+ static SIZE = 10;
15174
+ root;
15175
+ thumb;
15176
+ _vertical;
15177
+ _thumbPos = 0; // current thumb offset in px
15178
+ _thumbRatio = 0; // thumb size as fraction of track (0..1)
15179
+ _lastMouse = 0;
15180
+ _onDrag = null;
15181
+ get visible() { return !this.root.classList.contains('hidden'); }
15182
+ get isVertical() { return this._vertical; }
15183
+ constructor(vertical, onDrag) {
15184
+ this._vertical = vertical;
15185
+ this._onDrag = onDrag;
15186
+ this.root = LX.makeElement('div', `lexcodescrollbar hidden ${vertical ? 'vertical' : 'horizontal'}`);
15187
+ this.thumb = LX.makeElement('div');
15188
+ this.thumb.addEventListener('mousedown', (e) => this._onMouseDown(e));
15189
+ this.root.appendChild(this.thumb);
15190
+ }
15191
+ setThumbRatio(ratio) {
15192
+ this._thumbRatio = LX.clamp(ratio, 0, 1);
15193
+ const needed = this._thumbRatio < 1;
15194
+ this.root.classList.toggle('hidden', !needed);
15195
+ if (needed) {
15196
+ if (this._vertical)
15197
+ this.thumb.style.height = (this._thumbRatio * 100) + '%';
15198
+ else
15199
+ this.thumb.style.width = (this._thumbRatio * 100) + '%';
15200
+ }
15201
+ }
15202
+ syncToScroll(scrollPos, scrollMax) {
15203
+ if (scrollMax <= 0)
15204
+ return;
15205
+ const trackSize = this._vertical ? this.root.offsetHeight : this.root.offsetWidth;
15206
+ const thumbSize = this._vertical ? this.thumb.offsetHeight : this.thumb.offsetWidth;
15207
+ const available = trackSize - thumbSize;
15208
+ if (available <= 0)
15209
+ return;
15210
+ this._thumbPos = (scrollPos / scrollMax) * available;
15211
+ this._applyPosition();
15212
+ }
15213
+ applyDragDelta(delta, scrollMax) {
15214
+ const trackSize = this._vertical ? this.root.offsetHeight : this.root.offsetWidth;
15215
+ const thumbSize = this._vertical ? this.thumb.offsetHeight : this.thumb.offsetWidth;
15216
+ const available = trackSize - thumbSize;
15217
+ if (available <= 0)
15218
+ return 0;
15219
+ this._thumbPos = LX.clamp(this._thumbPos + delta, 0, available);
15220
+ this._applyPosition();
15221
+ return (this._thumbPos / available) * scrollMax;
15222
+ }
15223
+ _applyPosition() {
15224
+ if (this._vertical)
15225
+ this.thumb.style.top = this._thumbPos + 'px';
15226
+ else
15227
+ this.thumb.style.left = this._thumbPos + 'px';
15228
+ }
15229
+ _onMouseDown(e) {
15230
+ const doc = document;
15231
+ this._lastMouse = this._vertical ? e.clientY : e.clientX;
15232
+ const onMouseMove = (e) => {
15233
+ const current = this._vertical ? e.clientY : e.clientX;
15234
+ const delta = current - this._lastMouse;
15235
+ this._lastMouse = current;
15236
+ this._onDrag?.(delta);
15237
+ e.stopPropagation();
15238
+ e.preventDefault();
15239
+ };
15240
+ const onMouseUp = () => {
15241
+ doc.removeEventListener('mousemove', onMouseMove);
15242
+ doc.removeEventListener('mouseup', onMouseUp);
15243
+ };
15244
+ doc.addEventListener('mousemove', onMouseMove);
15245
+ doc.addEventListener('mouseup', onMouseUp);
15246
+ e.stopPropagation();
15247
+ e.preventDefault();
15248
+ }
15249
+ }
15250
+ /**
15251
+ * @class CodeEditor
15252
+ * The main editor class. Wires Document, Tokenizer, CursorSet, UndoManager
15253
+ * together with the DOM.
15254
+ */
15255
+ class CodeEditor {
15256
+ static __instances = [];
15257
+ language;
15258
+ symbolTable;
15259
+ // DOM:
15260
+ area;
15261
+ baseArea;
15262
+ codeArea;
15263
+ explorerArea;
15264
+ tabs;
15265
+ root;
15266
+ codeScroller;
15267
+ codeSizer;
15268
+ cursorsLayer;
15269
+ selectionsLayer;
15270
+ lineGutter;
15271
+ vScrollbar;
15272
+ hScrollbar;
15273
+ searchBox = null;
15274
+ searchLineBox = null;
15275
+ autocomplete = null;
15276
+ currentTab = null;
15277
+ statusPanel;
15278
+ leftStatusPanel;
15279
+ rightStatusPanel;
15280
+ explorer = null;
15281
+ // Measurements:
15282
+ charWidth = 0;
15283
+ lineHeight = 0;
15284
+ fontSize = 0;
15285
+ xPadding = 64; // 4rem left padding in pixels
15286
+ _cachedTabsHeight = 0;
15287
+ _cachedStatusPanelHeight = 0;
15288
+ // Editor options:
15289
+ skipInfo = false;
15290
+ disableEdition = false;
15291
+ skipTabs = false;
15292
+ useFileExplorer = false;
15293
+ useAutoComplete = true;
15294
+ allowAddScripts = true;
15295
+ allowClosingTabs = true;
15296
+ allowLoadingFiles = true;
15297
+ tabSize = 4;
15298
+ highlight = 'Plain Text';
15299
+ newTabOptions = null;
15300
+ customSuggestions = [];
15301
+ explorerName = 'EXPLORER';
15302
+ // Editor callbacks:
15303
+ onSave;
15304
+ onRun;
15305
+ onCtrlSpace;
15306
+ onCreateStatusPanel;
15307
+ onContextMenu;
15308
+ onNewTab;
15309
+ onSelectTab;
15310
+ onReady;
15311
+ onCreateFile;
15312
+ onCodeChange;
15313
+ _inputArea;
15314
+ // State:
15315
+ _lineStates = []; // tokenizer state at end of each line
15316
+ _lineElements = []; // <pre> element per line
15317
+ _openedTabs = {};
15318
+ _loadedTabs = {};
15319
+ _storedTabs = {};
15320
+ _focused = false;
15321
+ _composing = false;
15322
+ _keyChain = null;
15323
+ _wasPaired = false;
15324
+ _lastAction = '';
15325
+ _blinkerInterval = null;
15326
+ _cursorVisible = true;
15327
+ _cursorBlinkRate = 550;
15328
+ _clickCount = 0;
15329
+ _lastClickTime = 0;
15330
+ _lastClickLine = -1;
15331
+ _isSearchBoxActive = false;
15332
+ _isSearchLineBoxActive = false;
15333
+ _searchMatchCase = false;
15334
+ _lastTextFound = '';
15335
+ _lastSearchPos = null;
15336
+ _discardScroll = false;
15337
+ _isReady = false;
15338
+ _lastMaxLineLength = 0;
15339
+ _lastLineCount = 0;
15340
+ _isAutoCompleteActive = false;
15341
+ _selectedAutocompleteIndex = 0;
15342
+ _displayObservers;
15343
+ static CODE_MIN_FONT_SIZE = 9;
15344
+ static CODE_MAX_FONT_SIZE = 22;
15345
+ static PAIR_KEYS = { '"': '"', "'": "'", '`': '`', '(': ')', '{': '}', '[': ']' };
15346
+ get doc() {
15347
+ return this.currentTab.doc;
15348
+ }
15349
+ get undoManager() {
15350
+ return this.currentTab.undoManager;
15351
+ }
15352
+ get cursorSet() {
15353
+ return this.currentTab.cursorSet;
15354
+ }
15355
+ get codeContainer() {
15356
+ return this.currentTab.dom;
15357
+ }
15358
+ static getInstances() {
15359
+ return CodeEditor.__instances;
15360
+ }
15361
+ constructor(area, options = {}) {
15362
+ g.editor = this;
15363
+ CodeEditor.__instances.push(this);
15364
+ this.skipInfo = options.skipInfo ?? this.skipInfo;
15365
+ this.disableEdition = options.disableEdition ?? this.disableEdition;
15366
+ this.skipTabs = options.skipTabs ?? this.skipTabs;
15367
+ this.useFileExplorer = (options.fileExplorer ?? this.useFileExplorer) && !this.skipTabs;
15368
+ this.useAutoComplete = options.autocomplete ?? this.useAutoComplete;
15369
+ this.allowAddScripts = options.allowAddScripts ?? this.allowAddScripts;
15370
+ this.allowClosingTabs = options.allowClosingTabs ?? this.allowClosingTabs;
15371
+ this.allowLoadingFiles = options.allowLoadingFiles ?? this.allowLoadingFiles;
15372
+ this.highlight = options.highlight ?? this.highlight;
15373
+ this.newTabOptions = options.newTabOptions;
15374
+ this.customSuggestions = options.customSuggestions ?? [];
15375
+ this.explorerName = options.explorerName ?? this.explorerName;
15376
+ // Editor callbacks
15377
+ this.onSave = options.onSave;
15378
+ this.onRun = options.onRun;
15379
+ this.onCtrlSpace = options.onCtrlSpace;
15380
+ this.onCreateStatusPanel = options.onCreateStatusPanel;
15381
+ this.onContextMenu = options.onContextMenu;
15382
+ this.onNewTab = options.onNewTab;
15383
+ this.onSelectTab = options.onSelectTab;
15384
+ this.onReady = options.onReady;
15385
+ this.onCodeChange = options.onCodeChange;
15386
+ this.language = Tokenizer.getLanguage(this.highlight) ?? Tokenizer.getLanguage('Plain Text');
15387
+ this.symbolTable = new SymbolTable();
15388
+ // File explorer
15389
+ if (this.useFileExplorer) {
15390
+ let [explorerArea, editorArea] = area.split({ sizes: ['15%', '85%'] });
15391
+ // explorerArea.setLimitBox( 180, 20, 512 );
15392
+ this.explorerArea = explorerArea;
15393
+ let panel = new LX.Panel();
15394
+ panel.addTitle(this.explorerName);
15395
+ let sceneData = [];
15396
+ this.explorer = panel.addTree(null, sceneData, {
15397
+ filter: false,
15398
+ rename: false,
15399
+ skipDefaultIcon: true
15400
+ });
15401
+ this.explorer.on('dblClick', (event) => {
15402
+ const node = event.items[0];
15403
+ const name = node.id;
15404
+ this.loadTab(name);
15405
+ });
15406
+ this.explorer.on('beforeDelete', (event, resolve) => {
15407
+ return;
15408
+ });
15409
+ explorerArea.attach(panel);
15410
+ // Update area
15411
+ area = editorArea;
15412
+ }
15413
+ // Full editor
15414
+ area.root.className = LX.mergeClass(area.root.className, 'codebasearea overflow-hidden flex relative');
15415
+ this.baseArea = area;
15416
+ this.area = new LX.Area({
15417
+ className: 'lexcodeeditor flex flex-col outline-none overflow-hidden size-full select-none bg-inherit relative',
15418
+ skipAppend: true
15419
+ });
15420
+ const codeAreaClassName = 'lexcodearea scrollbar-hidden flex flex-row flex-auto-fill';
15421
+ if (!this.skipTabs) {
15422
+ this.tabs = this.area.addTabs({ contentClass: codeAreaClassName, onclose: (name) => {
15423
+ // this.closeTab triggers this onclose!
15424
+ delete this._openedTabs[name];
15425
+ } });
15426
+ LX.addClass(this.tabs.root.parentElement, 'rounded-t-lg');
15427
+ if (!this.disableEdition) {
15428
+ this.tabs.root.parentElement.addEventListener('dblclick', (e) => {
15429
+ if (!this.allowAddScripts)
15430
+ return;
15431
+ e.preventDefault();
15432
+ this._onCreateNewFile();
15433
+ });
15434
+ }
15435
+ this.codeArea = this.tabs.area;
15436
+ }
15437
+ else {
15438
+ this.codeArea = new LX.Area({ className: codeAreaClassName, skipAppend: true });
15439
+ this.area.attach(this.codeArea);
15440
+ }
15441
+ this.root = this.area.root;
15442
+ area.attach(this.root);
15443
+ this.codeScroller = this.codeArea.root;
15444
+ // Add Line numbers gutter, only the container, line numbers are in the same line div
15445
+ this.lineGutter = LX.makeElement('div', `w-16 overflow-hidden absolute top-0 bg-inherit z-10 ${this.skipTabs ? '' : 'mt-8'}`, null, this.codeScroller);
15446
+ // Add code sizer, which will have the code elements
15447
+ this.codeSizer = LX.makeElement('div', 'pseudoparent-tabs w-full', null, this.codeScroller);
15448
+ // Cursors and selections
15449
+ this.cursorsLayer = LX.makeElement('div', 'cursors', null, this.codeSizer);
15450
+ this.selectionsLayer = LX.makeElement('div', 'selections', null, this.codeSizer);
15451
+ // Custom scrollbars
15452
+ if (!this.disableEdition) {
15453
+ this.vScrollbar = new ScrollBar(true, (delta) => {
15454
+ const scrollMax = this.codeScroller.scrollHeight - this.codeScroller.clientHeight;
15455
+ this.codeScroller.scrollTop = this.vScrollbar.applyDragDelta(delta, scrollMax);
15456
+ this._discardScroll = true;
15457
+ });
15458
+ this.root.appendChild(this.vScrollbar.root);
15459
+ this.hScrollbar = new ScrollBar(false, (delta) => {
15460
+ const scrollMax = this.codeScroller.scrollWidth - this.codeScroller.clientWidth;
15461
+ this.codeScroller.scrollLeft = this.hScrollbar.applyDragDelta(delta, scrollMax);
15462
+ this._discardScroll = true;
15463
+ });
15464
+ this.root.appendChild(this.hScrollbar.root);
15465
+ // Sync scrollbar thumbs on native scroll
15466
+ this.codeScroller.addEventListener('scroll', () => {
15467
+ if (this._discardScroll) {
15468
+ this._discardScroll = false;
15469
+ return;
15470
+ }
15471
+ this._syncScrollBars();
15472
+ });
15473
+ // Touch events for native scrolling on mobile
15474
+ let touchStartX = 0;
15475
+ let touchStartY = 0;
15476
+ this.codeScroller.addEventListener('touchstart', (e) => {
15477
+ touchStartX = e.touches[0].clientX;
15478
+ touchStartY = e.touches[0].clientY;
15479
+ }, { passive: true });
15480
+ this.codeScroller.addEventListener('touchmove', (e) => {
15481
+ const touchX = e.touches[0].clientX;
15482
+ const touchY = e.touches[0].clientY;
15483
+ const deltaX = touchStartX - touchX;
15484
+ const deltaY = touchStartY - touchY;
15485
+ this.codeScroller.scrollLeft += deltaX;
15486
+ this.codeScroller.scrollTop += deltaY;
15487
+ touchStartX = touchX;
15488
+ touchStartY = touchY;
15489
+ }, { passive: true });
15490
+ // Wheel: Ctrl+Wheel for font zoom, Shift+Wheel for horizontal scroll
15491
+ this.codeScroller.addEventListener('wheel', (e) => {
15492
+ if (e.ctrlKey) {
15493
+ e.preventDefault();
15494
+ this._applyFontSizeOffset(e.deltaY < 0 ? 1 : -1);
15495
+ return;
15496
+ }
15497
+ if (e.shiftKey) {
15498
+ this.codeScroller.scrollLeft += e.deltaY > 0 ? 10 : -10;
15499
+ }
15500
+ }, { passive: false });
15501
+ }
15502
+ // Resize observer
15503
+ const codeResizeObserver = new ResizeObserver(() => {
15504
+ if (!this.currentTab)
15505
+ return;
15506
+ this.resize(true);
15507
+ });
15508
+ codeResizeObserver.observe(this.codeArea.root);
15509
+ if (!this.disableEdition) {
15510
+ // Add autocomplete box
15511
+ if (this.useAutoComplete) {
15512
+ this.autocomplete = LX.makeElement('div', 'autocomplete');
15513
+ this.codeArea.attach(this.autocomplete);
15514
+ }
15515
+ const searchBoxClass = 'searchbox bg-card min-w-96 absolute z-100 top-8 right-2 rounded-lg border-color overflow-y-scroll opacity-0';
15516
+ // Add search box
15517
+ {
15518
+ const box = LX.makeElement('div', searchBoxClass, null, this.codeArea);
15519
+ const searchPanel = new LX.Panel();
15520
+ box.appendChild(searchPanel.root);
15521
+ searchPanel.sameLine();
15522
+ const textComponent = searchPanel.addText(null, '', null, { placeholder: 'Find', inputClass: 'bg-secondary' });
15523
+ searchPanel.addButton(null, 'MatchCaseButton', (v) => {
15524
+ this._searchMatchCase = v;
15525
+ this._doSearch();
15526
+ }, { icon: 'CaseSensitive', selectable: true, buttonClass: 'link', title: 'Match Case',
15527
+ tooltip: true });
15528
+ searchPanel.addButton(null, 'up', () => this._doSearch(null, true), { icon: 'ArrowUp', buttonClass: 'ghost', title: 'Previous Match',
15529
+ tooltip: true });
15530
+ searchPanel.addButton(null, 'down', () => this._doSearch(), { icon: 'ArrowDown', buttonClass: 'ghost', title: 'Next Match',
15531
+ tooltip: true });
15532
+ searchPanel.addButton(null, 'x', this._doHideSearch.bind(this), { icon: 'X', buttonClass: 'ghost', title: 'Close',
15533
+ tooltip: true });
15534
+ searchPanel.endLine();
15535
+ const searchInput = textComponent.root.querySelector('input');
15536
+ searchInput?.addEventListener('keyup', (e) => {
15537
+ if (e.key == 'Escape')
15538
+ this._doHideSearch();
15539
+ else if (e.key == 'Enter')
15540
+ this._doSearch(e.target.value, !!e.shiftKey);
15541
+ });
15542
+ this.searchBox = box;
15543
+ }
15544
+ // Add search LINE box
15545
+ {
15546
+ const box = LX.makeElement('div', searchBoxClass, null, this.codeArea);
15547
+ const searchPanel = new LX.Panel();
15548
+ box.appendChild(searchPanel.root);
15549
+ searchPanel.sameLine();
15550
+ const textComponent = searchPanel.addText(null, '', (value) => {
15551
+ searchInput.value = ':' + value.replaceAll(':', '');
15552
+ this._doGotoLine(parseInt(searchInput.value.slice(1)));
15553
+ }, { className: 'flex-auto-fill', placeholder: 'Go to line', trigger: 'input' });
15554
+ searchPanel.addButton(null, 'x', this._doHideSearch.bind(this), { icon: 'X', title: 'Close', buttonClass: 'ghost',
15555
+ tooltip: true });
15556
+ searchPanel.endLine();
15557
+ const searchInput = textComponent.root.querySelector('input');
15558
+ searchInput.addEventListener('keyup', (e) => {
15559
+ if (e.key == 'Escape')
15560
+ this._doHideSearch();
15561
+ });
15562
+ searchPanel.addText(null, 'Type a line number to go to (from 0 to 0).', null, { disabled: true, inputClass: 'text-xs bg-none', signal: '@line-number-range' });
15563
+ this.searchLineBox = box;
15564
+ }
15565
+ }
15566
+ // Hidden textarea for capturing keyboard input (dead keys, IME, etc.)
15567
+ this._inputArea = LX.makeElement('textarea', 'absolute opacity-0 w-[1px] h-[1px] top-0 left-0 overflow-hidden resize-none', '', this.root);
15568
+ this._inputArea.setAttribute('autocorrect', 'off');
15569
+ this._inputArea.setAttribute('autocapitalize', 'off');
15570
+ this._inputArea.setAttribute('spellcheck', 'false');
15571
+ this._inputArea.tabIndex = 0;
15572
+ // Events:
15573
+ this._inputArea.addEventListener('keydown', this._onKeyDown.bind(this));
15574
+ this._inputArea.addEventListener('compositionstart', () => this._composing = true);
15575
+ this._inputArea.addEventListener('compositionend', (e) => {
15576
+ this._composing = false;
15577
+ this._inputArea.value = '';
15578
+ if (e.data)
15579
+ this._doInsertChar(e.data);
15580
+ });
15581
+ this._inputArea.addEventListener('input', () => {
15582
+ if (this._composing)
15583
+ return;
15584
+ const val = this._inputArea.value;
15585
+ if (val) {
15586
+ this._inputArea.value = '';
15587
+ this._doInsertChar(val);
15588
+ }
15589
+ });
15590
+ this._inputArea.addEventListener('focus', () => this._setFocused(true));
15591
+ this._inputArea.addEventListener('blur', () => this._setFocused(false));
15592
+ this.codeArea.root.addEventListener('mousedown', this._onMouseDown.bind(this));
15593
+ this.codeArea.root.addEventListener('contextmenu', this._onMouseDown.bind(this));
15594
+ // Bottom status panel
15595
+ this.statusPanel = this._createStatusPanel(options);
15596
+ if (this.statusPanel) {
15597
+ // Don't do this.area.attach here, since it will append to the tabs area.
15598
+ this.area.root.appendChild(this.statusPanel.root);
15599
+ }
15600
+ if (this.allowAddScripts) {
15601
+ this.onCreateFile = options.onCreateFile;
15602
+ this.addTab('+', {
15603
+ selected: false,
15604
+ title: 'Create file'
15605
+ });
15606
+ }
15607
+ // Starter code tab container
15608
+ if (options.defaultTab ?? true) {
15609
+ this.addTab(options.name || 'untitled', {
15610
+ language: this.highlight,
15611
+ title: options.title
15612
+ });
15613
+ // Initial render
15614
+ this._renderAllLines();
15615
+ this._renderCursors();
15616
+ }
15617
+ this._init();
15618
+ }
15619
+ _init() {
15620
+ if (this._displayObservers)
15621
+ return;
15622
+ this._isReady = false;
15623
+ const root = this.root;
15624
+ const _isVisible = () => {
15625
+ return (root.offsetParent !== null
15626
+ && root.clientWidth > 0
15627
+ && root.clientHeight > 0);
15628
+ };
15629
+ const _tryPrepare = async () => {
15630
+ if (this._isReady)
15631
+ return;
15632
+ if (!_isVisible())
15633
+ return;
15634
+ // Stop observing once prepared
15635
+ intersectionObserver.disconnect();
15636
+ resizeObserver.disconnect();
15637
+ await this._setupEditorWhenVisible();
15638
+ };
15639
+ // IntersectionObserver (for viewport)
15640
+ const intersectionObserver = new IntersectionObserver((entries) => {
15641
+ for (const entry of entries) {
15642
+ if (entry.isIntersecting) {
15643
+ _tryPrepare();
15644
+ }
15645
+ }
15646
+ });
15647
+ intersectionObserver.observe(root);
15648
+ // ResizeObserver (for display property changes)
15649
+ const resizeObserver = new ResizeObserver(() => {
15650
+ _tryPrepare();
15651
+ });
15652
+ resizeObserver.observe(root);
15653
+ // Fallback polling (don't use it for now)
15654
+ // const interval = setInterval( () => {
15655
+ // if ( this._isReady ) {
15656
+ // clearInterval( interval );
15657
+ // return;
15658
+ // }
15659
+ // _tryPrepare();
15660
+ // }, 250 );
15661
+ this._displayObservers = {
15662
+ intersectionObserver,
15663
+ resizeObserver
15664
+ // interval,
15665
+ };
15666
+ }
15667
+ clear() {
15668
+ console.assert(this.rightStatusPanel && this.leftStatusPanel, 'No panels to clear.');
15669
+ this.rightStatusPanel.clear();
15670
+ this.leftStatusPanel.clear();
15671
+ }
15672
+ addExplorerItem(item) {
15673
+ if (!this.explorer) {
15674
+ return;
15675
+ }
15676
+ if (!this.explorer.innerTree.data.find((value, index) => value.id === item.id)) {
15677
+ this.explorer.innerTree.data.push(item);
15678
+ }
15679
+ }
15680
+ ;
15681
+ setText(text) {
15682
+ if (!this.currentTab)
15683
+ return;
15684
+ this.doc.setText(text);
15685
+ this.cursorSet.set(0, 0);
15686
+ this.undoManager.clear();
15687
+ this._lineStates = [];
15688
+ this._renderAllLines();
15689
+ this._renderCursors();
15690
+ this._renderSelections();
15691
+ this.resize(true);
15692
+ }
15693
+ appendText(text) {
15694
+ const cursor = this.cursorSet.getPrimary();
15695
+ const { line, col } = cursor.head;
15696
+ const op = this.doc.insert(line, col, text);
15697
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
15698
+ // Move cursor to end of inserted text
15699
+ const lines = text.split(/\r?\n/);
15700
+ if (lines.length === 1) {
15701
+ cursor.head = { line, col: col + text.length };
15702
+ }
15703
+ else {
15704
+ cursor.head = { line: line + lines.length - 1, col: lines[lines.length - 1].length };
15705
+ }
15706
+ cursor.anchor = { ...cursor.head };
15707
+ this._rebuildLines();
15708
+ this._renderCursors();
15709
+ this._renderSelections();
15710
+ this.resize();
15711
+ this._scrollCursorIntoView();
15712
+ }
15713
+ getText() {
15714
+ return this.doc.getText();
15715
+ }
15716
+ setLanguage(name, extension) {
15717
+ const lang = Tokenizer.getLanguage(name);
15718
+ if (!lang)
15719
+ return;
15720
+ this.language = lang;
15721
+ if (this.currentTab) {
15722
+ this.currentTab.language = name;
15723
+ if (!this.skipTabs) {
15724
+ this.tabs.setIcon(this.currentTab.name, getLanguageIcon(lang, extension));
15725
+ }
15726
+ }
15727
+ this._lineStates = [];
15728
+ this._renderAllLines();
15729
+ LX.emitSignal('@highlight', name);
15730
+ }
15731
+ focus() {
15732
+ this._inputArea.focus();
15733
+ }
15734
+ addTab(name, options = {}) {
15735
+ const isNewTabButton = name === '+';
15736
+ const dom = LX.makeElement('div', 'code');
15737
+ const langName = options.language ?? 'Plain Text';
15738
+ const langDef = Tokenizer.getLanguage(langName);
15739
+ const extension = LX.getExtension(name);
15740
+ const icon = isNewTabButton ? null : getLanguageIcon(langDef, extension);
15741
+ const selected = options.selected ?? true;
15742
+ const codeTab = {
15743
+ name,
15744
+ dom,
15745
+ doc: new CodeDocument(this.onCodeChange),
15746
+ cursorSet: new CursorSet(),
15747
+ undoManager: new UndoManager(),
15748
+ language: langName,
15749
+ title: options.title ?? name
15750
+ };
15751
+ this._openedTabs[name] = codeTab;
15752
+ this._loadedTabs[name] = codeTab;
15753
+ if (this.useFileExplorer && !isNewTabButton) {
15754
+ this.addExplorerItem({ id: name, skipVisibility: true, icon });
15755
+ this.explorer.innerTree.frefresh(name);
15756
+ }
15757
+ if (!this.skipTabs) {
15758
+ this.tabs.add(name, dom, {
15759
+ selected,
15760
+ icon,
15761
+ fixed: isNewTabButton,
15762
+ title: codeTab.title,
15763
+ onSelect: this._onSelectTab.bind(this, isNewTabButton),
15764
+ onContextMenu: this._onContextMenuTab.bind(this, isNewTabButton),
15765
+ allowDelete: this.allowClosingTabs,
15766
+ indexOffset: options.indexOffset
15767
+ });
15768
+ }
15769
+ // Move into the sizer..
15770
+ this.codeSizer.appendChild(dom);
15771
+ if (options.text) {
15772
+ codeTab.doc.setText(options.text);
15773
+ codeTab.cursorSet.set(0, 0);
15774
+ codeTab.undoManager.clear();
15775
+ this._renderAllLines();
15776
+ this._renderCursors();
15777
+ this._renderSelections();
15778
+ this._resetGutter();
15779
+ }
15780
+ if (selected) {
15781
+ this.currentTab = codeTab;
15782
+ this._updateDataInfoPanel('@tab-name', name);
15783
+ this.setLanguage(langName, extension);
15784
+ }
15785
+ return codeTab;
15786
+ }
15787
+ loadTab(name) {
15788
+ if (this._openedTabs[name]) {
15789
+ this.tabs.select(name);
15790
+ return;
15791
+ }
15792
+ const tab = this._loadedTabs[name];
15793
+ if (tab) {
15794
+ this._openedTabs[name] = tab;
15795
+ this.tabs.add(name, tab.dom, {
15796
+ selected: true,
15797
+ // icon,
15798
+ title: tab.title,
15799
+ onSelect: this._onSelectTab.bind(this),
15800
+ onContextMenu: this._onContextMenuTab.bind(this),
15801
+ allowDelete: this.allowClosingTabs
15802
+ });
15803
+ // Move into the sizer..
15804
+ this.codeSizer.appendChild(tab.dom);
15805
+ this.currentTab = tab;
15806
+ this._updateDataInfoPanel('@tab-name', name);
15807
+ return;
15808
+ }
15809
+ this.addTab(name, this._storedTabs[name] ?? null);
15810
+ }
15811
+ closeTab(name) {
15812
+ if (!this.allowClosingTabs)
15813
+ return;
15814
+ this.tabs.delete(name);
15815
+ const tab = this._openedTabs[name];
15816
+ if (tab) {
15817
+ tab.dom.remove();
15818
+ delete this._openedTabs[name];
15819
+ }
15820
+ // If we closed the current tab, switch to the first available
15821
+ if (this.currentTab?.name === name) {
15822
+ const remaining = Object.keys(this._openedTabs).filter(k => k !== '+');
15823
+ if (remaining.length > 0) {
15824
+ this.tabs.select(remaining[0]);
15825
+ }
15826
+ else {
15827
+ this.currentTab = null;
15828
+ }
15829
+ }
15830
+ }
15831
+ setCustomSuggestions(suggestions) {
15832
+ if (!suggestions || suggestions.constructor !== Array) {
15833
+ console.warn('suggestions should be a string array!');
15834
+ return;
15835
+ }
15836
+ this.customSuggestions = suggestions;
15837
+ }
15838
+ loadFile(file, options = {}) {
15839
+ const onLoad = (text, name) => {
15840
+ // Remove Carriage Return in some cases and sub tabs using spaces
15841
+ text = text.replaceAll('\r', '').replaceAll(/\t|\\t/g, ' '.repeat(this.tabSize));
15842
+ const ext = LX.getExtension(name);
15843
+ const lang = options.language ?? (Tokenizer.getLanguage(options.language)
15844
+ ?? (Tokenizer.getLanguageByExtension(ext) ?? Tokenizer.getLanguage('Plain Text')));
15845
+ const langName = lang.name;
15846
+ if (this.useFileExplorer || this.skipTabs) {
15847
+ this._storedTabs[name] = {
15848
+ text,
15849
+ title: options.title ?? name,
15850
+ language: langName,
15851
+ ...options
15852
+ };
15853
+ if (this.useFileExplorer) {
15854
+ this.addExplorerItem({ id: name, skipVisibility: true, icon: getLanguageIcon(lang, ext) });
15855
+ this.explorer.innerTree.frefresh(name);
15856
+ }
15857
+ }
15858
+ else {
15859
+ this.addTab(name, {
15860
+ selected: true,
15861
+ title: options.title ?? name,
15862
+ language: langName
15863
+ });
15864
+ this.doc.setText(text);
15865
+ this.setLanguage(langName, ext);
15866
+ this.cursorSet.set(0, 0);
15867
+ this.undoManager.clear();
15868
+ this._renderCursors();
15869
+ this._renderSelections();
15870
+ this._resetGutter();
15871
+ }
15872
+ if (options.callback) {
15873
+ options.callback(name, text);
15874
+ }
15875
+ };
15876
+ if (typeof file === 'string') {
15877
+ const url = file;
15878
+ const name = options.filename ?? url.substring(url.lastIndexOf('/') + 1);
15879
+ LX.request({ url, success: (text) => {
15880
+ onLoad(text, name);
15881
+ } });
15882
+ }
15883
+ else {
15884
+ const fr = new FileReader();
15885
+ fr.readAsText(file);
15886
+ fr.onload = (e) => {
15887
+ const text = e.currentTarget.result;
15888
+ onLoad(text, file.name);
15889
+ };
15890
+ }
15891
+ }
15892
+ async loadFiles(files, onComplete, async = false) {
15893
+ if (!files || files.length === 0) {
15894
+ onComplete?.(this, [], 0);
15895
+ return;
15896
+ }
15897
+ const results = [];
15898
+ for (const filePath of files) {
15899
+ try {
15900
+ const text = await LX.requestFileAsync(filePath, 'text');
15901
+ // Process the loaded file
15902
+ const name = filePath.substring(filePath.lastIndexOf('/') + 1);
15903
+ const processedText = text.replaceAll('\r', '').replaceAll(/\t|\\t/g, ' '.repeat(this.tabSize));
15904
+ const ext = LX.getExtension(name);
15905
+ const lang = Tokenizer.getLanguageByExtension(ext) ?? Tokenizer.getLanguage('Plain Text');
15906
+ const langName = lang.name;
15907
+ if (this.useFileExplorer || this.skipTabs) {
15908
+ this._storedTabs[name] = {
15909
+ text: processedText,
15910
+ title: name,
15911
+ language: langName
15912
+ };
15913
+ if (this.useFileExplorer) {
15914
+ this.addExplorerItem({ id: name, skipVisibility: true, icon: getLanguageIcon(lang, ext) });
15915
+ this.explorer.innerTree.frefresh(name);
15916
+ }
15917
+ }
15918
+ else {
15919
+ this.addTab(name, {
15920
+ selected: results.length === 0, // Select first tab only
15921
+ title: name,
15922
+ language: langName
15923
+ });
15924
+ if (results.length === 0) {
15925
+ this.doc.setText(processedText);
15926
+ this.setLanguage(langName, ext);
15927
+ this.cursorSet.set(0, 0);
15928
+ this.undoManager.clear();
15929
+ this._renderCursors();
15930
+ this._renderSelections();
15931
+ this._resetGutter();
15932
+ }
15933
+ }
15934
+ results.push({ filePath, name, success: true });
15935
+ }
15936
+ catch (error) {
15937
+ console.error(`[LX.CodeEditor] Failed to load file: ${filePath}`, error);
15938
+ results.push({ filePath, success: false, error });
15939
+ }
15940
+ }
15941
+ onComplete?.(this, results, results.length);
15942
+ }
15943
+ async _setupEditorWhenVisible() {
15944
+ // Load any font size from local storage
15945
+ // If not, use default size and make sure it's sync by not hardcoding a number by default here
15946
+ const savedFontSize = window.localStorage.getItem('lexcodeeditor-font-size');
15947
+ if (savedFontSize) {
15948
+ await this._setFontSize(parseInt(savedFontSize), false);
15949
+ }
15950
+ else {
15951
+ const r = document.querySelector(':root');
15952
+ const s = getComputedStyle(r);
15953
+ this.fontSize = parseInt(s.getPropertyValue('--code-editor-font-size'));
15954
+ await this._measureChar();
15955
+ }
15956
+ LX.emitSignal('@font-size', this.fontSize);
15957
+ LX.doAsync(() => {
15958
+ if (!this._isReady) {
15959
+ this._isReady = true;
15960
+ if (this.onReady) {
15961
+ this.onReady(this);
15962
+ }
15963
+ console.log(`[LX.CodeEditor] Ready! (font size: ${this.fontSize}px, char size: ${this.charWidth}px)`);
15964
+ }
15965
+ }, 20);
15966
+ }
15967
+ _onSelectTab(isNewTabButton, event, name) {
15968
+ if (this.disableEdition) {
15969
+ return;
15970
+ }
15971
+ if (isNewTabButton) {
15972
+ this._onNewTab(event);
15973
+ return;
15974
+ }
15975
+ this.currentTab = this._openedTabs[name];
15976
+ this._updateDataInfoPanel('@tab-name', name);
15977
+ this.language = Tokenizer.getLanguage(this.currentTab.language) ?? Tokenizer.getLanguage('Plain Text');
15978
+ LX.emitSignal('@highlight', this.currentTab.language);
15979
+ this._renderAllLines();
15980
+ this._afterCursorMove();
15981
+ if (!isNewTabButton && this.onSelectTab) {
15982
+ this.onSelectTab(name, this);
15983
+ }
15984
+ }
15985
+ _onNewTab(event) {
15986
+ if (this.onNewTab) {
15987
+ this.onNewTab(event);
15988
+ return;
15989
+ }
15990
+ const dmOptions = this.newTabOptions ?? [
15991
+ { name: 'Create file', icon: 'FilePlus', callback: this._onCreateNewFile.bind(this) },
15992
+ { name: 'Load file', icon: 'FileUp', disabled: !this.allowLoadingFiles, callback: this._doLoadFromFile.bind(this) }
15993
+ ];
15994
+ LX.addDropdownMenu(event.target, dmOptions, { side: 'bottom', align: 'start' });
15995
+ }
15996
+ _onCreateNewFile() {
15997
+ let options = {};
15998
+ if (this.onCreateFile) {
15999
+ options = this.onCreateFile(this);
16000
+ // Skip adding new file
16001
+ if (!options) {
16002
+ return;
16003
+ }
16004
+ }
16005
+ const name = options.name ?? 'unnamed.js';
16006
+ this.addTab(name, {
16007
+ selected: true,
16008
+ title: name,
16009
+ indexOffset: options.indexOffset,
16010
+ language: options.language ?? 'JavaScript'
16011
+ });
16012
+ this._renderAllLines();
16013
+ this._renderCursors();
16014
+ this._renderSelections();
16015
+ this._resetGutter();
16016
+ }
16017
+ _onContextMenuTab(isNewTabButton = false, event, name) {
16018
+ if (isNewTabButton) {
16019
+ return;
16020
+ }
16021
+ LX.addDropdownMenu(event.target, [
16022
+ { name: 'Close', kbd: 'MWB', disabled: !this.allowClosingTabs, callback: () => {
16023
+ this.closeTab(name);
16024
+ } },
16025
+ { name: 'Close Others', disabled: !this.allowClosingTabs, callback: () => {
16026
+ for (const key of Object.keys(this.tabs.tabs)) {
16027
+ if (key === '+' || key === name)
16028
+ continue;
16029
+ this.closeTab(key);
16030
+ }
16031
+ } },
16032
+ { name: 'Close All', disabled: !this.allowClosingTabs, callback: () => {
16033
+ for (const key of Object.keys(this.tabs.tabs)) {
16034
+ if (key === '+')
16035
+ continue;
16036
+ this.closeTab(key);
16037
+ }
16038
+ } },
16039
+ null,
16040
+ { name: 'Copy Path', icon: 'Copy', callback: () => {
16041
+ navigator.clipboard.writeText(this._openedTabs[name].path ?? '');
16042
+ } }
16043
+ ], { side: 'bottom', align: 'start', event });
16044
+ }
16045
+ async _measureChar() {
16046
+ const parentContainer = LX.makeContainer(null, 'lexcodeeditor', '', document.body);
16047
+ const container = LX.makeContainer(null, 'code', '', parentContainer);
16048
+ const line = document.createElement('pre');
16049
+ container.appendChild(line);
16050
+ const measurer = document.createElement('span');
16051
+ measurer.className = 'codechar';
16052
+ measurer.style.visibility = 'hidden';
16053
+ measurer.textContent = 'M';
16054
+ line.appendChild(measurer);
16055
+ // Force load the font before measuring
16056
+ const computedStyle = getComputedStyle(measurer);
16057
+ const fontFamily = computedStyle.fontFamily;
16058
+ const fontSize = computedStyle.fontSize;
16059
+ const fontWeight = computedStyle.fontWeight || 'normal';
16060
+ const fontStyle = computedStyle.fontStyle || 'normal';
16061
+ const fontString = `${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}`;
16062
+ try {
16063
+ await document.fonts.load(fontString);
16064
+ }
16065
+ catch (e) {
16066
+ console.warn('[LX.CodeEditor] Failed to load font:', fontString, e);
16067
+ }
16068
+ // Use requestAnimationFrame to ensure the element is rendered
16069
+ requestAnimationFrame(() => {
16070
+ const rect = measurer.getBoundingClientRect();
16071
+ this.charWidth = rect.width || 7;
16072
+ this.lineHeight = parseFloat(getComputedStyle(this.root).getPropertyValue('--code-editor-row-height')) || 20;
16073
+ LX.deleteElement(parentContainer);
16074
+ // Re-render cursors with correct measurements
16075
+ this._renderCursors();
16076
+ this._renderSelections();
16077
+ this.resize(true);
16078
+ });
16079
+ }
16080
+ _createStatusPanel(options = {}) {
16081
+ if (this.skipInfo) {
16082
+ return;
16083
+ }
16084
+ let panel = new LX.Panel({ className: 'lexcodetabinfo bg-inherit flex flex-row flex-auto-keep', height: 'auto' });
16085
+ if (this.onCreateStatusPanel) {
16086
+ this.onCreateStatusPanel(panel, this);
16087
+ }
16088
+ let leftStatusPanel = this.leftStatusPanel = new LX.Panel({ id: 'FontSizeZoomStatusComponent',
16089
+ className: 'pad-xs content-center items-center flex-auto-keep', width: 'auto', height: 'auto' });
16090
+ leftStatusPanel.sameLine();
16091
+ // Zoom Component
16092
+ leftStatusPanel.addButton(null, 'ZoomOutButton', this._decreaseFontSize.bind(this), { icon: 'ZoomOut', buttonClass: 'ghost sm',
16093
+ title: 'Zoom Out', tooltip: true });
16094
+ leftStatusPanel.addLabel(this.fontSize, { fit: true, signal: '@font-size' });
16095
+ leftStatusPanel.addButton(null, 'ZoomInButton', this._increaseFontSize.bind(this), { icon: 'ZoomIn', buttonClass: 'ghost sm',
16096
+ title: 'Zoom In', tooltip: true });
16097
+ leftStatusPanel.endLine('justify-start');
16098
+ panel.attach(leftStatusPanel.root);
16099
+ // Filename cursor data
16100
+ let rightStatusPanel = this.rightStatusPanel = new LX.Panel({ className: 'pad-xs content-center items-center', height: 'auto' });
16101
+ rightStatusPanel.sameLine();
16102
+ rightStatusPanel.addLabel(this.currentTab?.title ?? '', { id: 'EditorFilenameStatusComponent', fit: true, inputClass: 'text-xs',
16103
+ signal: '@tab-name' });
16104
+ rightStatusPanel.addButton(null, 'Ln 1, Col 1', this._doOpenLineSearch.bind(this), {
16105
+ id: 'EditorSelectionStatusComponent',
16106
+ buttonClass: 'outline xs',
16107
+ fit: true,
16108
+ signal: '@cursor-data'
16109
+ });
16110
+ const tabSizeButton = rightStatusPanel.addButton(null, 'Spaces: ' + this.tabSize, (value, event) => {
16111
+ const _onNewTabSize = (v) => {
16112
+ this.tabSize = parseInt(v);
16113
+ this._rebuildLines();
16114
+ this._updateDataInfoPanel('@tab-spaces', `Spaces: ${this.tabSize}`);
16115
+ };
16116
+ const dd = LX.addDropdownMenu(tabSizeButton.root, ['2', '4', '8'].map((v) => { return { name: v, className: 'w-full place-content-center', callback: _onNewTabSize }; }), { side: 'top', align: 'end' });
16117
+ LX.addClass(dd.root, 'min-w-16! items-center');
16118
+ }, { id: 'EditorIndentationStatusComponent', buttonClass: 'outline xs', signal: '@tab-spaces' });
16119
+ const langButton = rightStatusPanel.addButton('<b>{ }</b>', this.highlight, (value, event) => {
16120
+ const dd = LX.addDropdownMenu(langButton.root, Tokenizer.getRegisteredLanguages().map((v) => {
16121
+ const lang = Tokenizer.getLanguage(v);
16122
+ const icon = getLanguageIcon(lang);
16123
+ const iconData = icon ? icon.split(' ') : [];
16124
+ return {
16125
+ name: v,
16126
+ icon: iconData[0],
16127
+ className: 'w-full text-xs px-3',
16128
+ svgClass: iconData.slice(1).join(' '),
16129
+ callback: (v) => this.setLanguage(v)
16130
+ };
16131
+ }), { side: 'top', align: 'end' });
16132
+ LX.addClass(dd.root, 'min-w-min! items-center');
16133
+ }, { id: 'EditorLanguageStatusComponent', nameWidth: 'auto', buttonClass: 'outline xs', signal: '@highlight', title: '' });
16134
+ rightStatusPanel.endLine('justify-end');
16135
+ panel.attach(rightStatusPanel.root);
16136
+ const itemVisibilityMap = {
16137
+ 'Font Size Zoom': options.statusShowFontSizeZoom ?? true,
16138
+ 'Editor Filename': options.statusShowEditorFilename ?? true,
16139
+ 'Editor Selection': options.statusShowEditorSelection ?? true,
16140
+ 'Editor Indentation': options.statusShowEditorIndentation ?? true,
16141
+ 'Editor Language': options.statusShowEditorLanguage ?? true
16142
+ };
16143
+ const _setVisibility = (itemName) => {
16144
+ const b = panel.root.querySelector(`#${itemName.replaceAll(' ', '')}StatusComponent`);
16145
+ console.assert(b, `${itemName} has no status button!`);
16146
+ b.classList.toggle('hidden', !itemVisibilityMap[itemName]);
16147
+ };
16148
+ for (const [itemName, v] of Object.entries(itemVisibilityMap)) {
16149
+ _setVisibility(itemName);
16150
+ }
16151
+ panel.root.addEventListener('contextmenu', (e) => {
16152
+ if (e.target
16153
+ && (e.target.classList.contains('lexpanel')
16154
+ || e.target.classList.contains('lexinlinecomponents'))) {
16155
+ return;
16156
+ }
16157
+ const menuOptions = Object.keys(itemVisibilityMap).map((itemName, idx) => {
16158
+ const item = {
16159
+ name: itemName,
16160
+ icon: 'Check',
16161
+ callback: () => {
16162
+ itemVisibilityMap[itemName] = !itemVisibilityMap[itemName];
16163
+ _setVisibility(itemName);
16164
+ }
16165
+ };
16166
+ if (!itemVisibilityMap[itemName])
16167
+ delete item.icon;
16168
+ return item;
16169
+ });
16170
+ LX.addDropdownMenu(e.target, menuOptions, { side: 'top', align: 'start' });
16171
+ });
16172
+ return panel;
16173
+ }
16174
+ _updateDataInfoPanel(signal, value) {
16175
+ if (this.skipInfo)
16176
+ return;
16177
+ if (this.cursorSet.cursors.length > 1) {
16178
+ value = '';
16179
+ }
16180
+ LX.emitSignal(signal, value);
16181
+ }
16182
+ /**
16183
+ * Tokenize a line and return its innerHTML with syntax highlighting spans.
16184
+ */
16185
+ _tokenizeLine(lineIndex) {
16186
+ const prevState = lineIndex > 0
16187
+ ? (this._lineStates[lineIndex - 1] ?? Tokenizer.initialState())
16188
+ : Tokenizer.initialState();
16189
+ const lineText = this.doc.getLine(lineIndex);
16190
+ const result = Tokenizer.tokenizeLine(lineText, this.language, prevState);
16191
+ // Build HTML
16192
+ const langClass = this.language.name.toLowerCase().replace(/[^a-z]/g, '');
16193
+ let html = '';
16194
+ for (const token of result.tokens) {
16195
+ const cls = TOKEN_CLASS_MAP[token.type];
16196
+ const escaped = token.value
16197
+ .replace(/&/g, '&amp;')
16198
+ .replace(/</g, '&lt;')
16199
+ .replace(/>/g, '&gt;');
16200
+ if (cls) {
16201
+ html += `<span class="${cls} ${langClass}">${escaped}</span>`;
16202
+ }
16203
+ else {
16204
+ html += escaped;
16205
+ }
16206
+ }
16207
+ return { html: html || '&nbsp;', endState: result.state, tokens: result.tokens };
16208
+ }
16209
+ /**
16210
+ * Update symbol table for a line.
16211
+ */
16212
+ _updateSymbolsForLine(lineIndex) {
16213
+ const lineText = this.doc.getLine(lineIndex);
16214
+ this.symbolTable.updateScopeForLine(lineIndex, lineText);
16215
+ this.symbolTable.removeLineSymbols(lineIndex);
16216
+ const { tokens } = this._tokenizeLine(lineIndex);
16217
+ const symbols = parseSymbolsFromLine(lineText, tokens, lineIndex, this.symbolTable);
16218
+ for (const symbol of symbols) {
16219
+ this.symbolTable.addSymbol(symbol);
16220
+ }
16221
+ }
16222
+ /**
16223
+ * Render all lines from scratch.
16224
+ */
16225
+ _renderAllLines() {
16226
+ if (!this.currentTab)
16227
+ return;
16228
+ this.codeContainer.innerHTML = '';
16229
+ this._lineElements = [];
16230
+ this._lineStates = [];
16231
+ this.symbolTable.clear(); // Clear all symbols on full rebuild
16232
+ for (let i = 0; i < this.doc.lineCount; i++) {
16233
+ this._appendLineElement(i);
16234
+ }
16235
+ }
16236
+ /**
16237
+ * Gets the html for the line gutter.
16238
+ */
16239
+ _getGutterHtml(lineIndex) {
16240
+ return `<span class="line-gutter">${lineIndex + 1}</span>`;
16241
+ }
16242
+ /**
16243
+ * Create and append a <pre> element for a line.
16244
+ */
16245
+ _appendLineElement(lineIndex) {
16246
+ const { html, endState } = this._tokenizeLine(lineIndex);
16247
+ this._lineStates[lineIndex] = endState;
16248
+ const pre = document.createElement('pre');
16249
+ pre.innerHTML = this._getGutterHtml(lineIndex) + html;
16250
+ this.codeContainer.appendChild(pre);
16251
+ this._lineElements[lineIndex] = pre;
16252
+ // Update symbols for this line
16253
+ this._updateSymbolsForLine(lineIndex);
16254
+ }
16255
+ /**
16256
+ * Re-render a single line's content (after editing).
16257
+ */
16258
+ _updateLine(lineIndex) {
16259
+ const { html, endState } = this._tokenizeLine(lineIndex);
16260
+ const oldState = this._lineStates[lineIndex];
16261
+ this._lineStates[lineIndex] = endState;
16262
+ if (this._lineElements[lineIndex]) {
16263
+ this._lineElements[lineIndex].innerHTML = this._getGutterHtml(lineIndex) + html;
16264
+ }
16265
+ // Update symbols for this line
16266
+ this._updateSymbolsForLine(lineIndex);
16267
+ // If the tokenizer state changed (e.g. opened/closed a block comment),
16268
+ // re-render subsequent lines until states stabilize
16269
+ if (!this._statesEqual(oldState, endState)) {
16270
+ for (let i = lineIndex + 1; i < this.doc.lineCount; i++) {
16271
+ const { html: nextHtml, endState: nextEnd } = this._tokenizeLine(i);
16272
+ const nextOld = this._lineStates[i];
16273
+ this._lineStates[i] = nextEnd;
16274
+ if (this._lineElements[i]) {
16275
+ this._lineElements[i].innerHTML = this._getGutterHtml(i) + nextHtml;
16276
+ }
16277
+ this._updateSymbolsForLine(i); // Update symbols for cascaded lines too
16278
+ if (this._statesEqual(nextOld, nextEnd))
16279
+ break;
16280
+ }
16281
+ }
16282
+ }
16283
+ /**
16284
+ * Rebuild line elements after structural changes (insert/delete lines).
16285
+ */
16286
+ _rebuildLines() {
16287
+ // Diff: if count matches, just update content; otherwise full rebuild
16288
+ if (this._lineElements.length === this.doc.lineCount) {
16289
+ for (let i = 0; i < this.doc.lineCount; i++) {
16290
+ this._updateLine(i);
16291
+ }
16292
+ }
16293
+ else {
16294
+ this._renderAllLines();
16295
+ }
16296
+ this.resize();
16297
+ }
16298
+ _statesEqual(a, b) {
16299
+ if (!a)
16300
+ return false;
16301
+ if (a.stack.length !== b.stack.length)
16302
+ return false;
16303
+ for (let i = 0; i < a.stack.length; i++) {
16304
+ if (a.stack[i] !== b.stack[i])
16305
+ return false;
16306
+ }
16307
+ return true;
16308
+ }
16309
+ _updateActiveLine() {
16310
+ const hasSelection = this.cursorSet.hasSelection();
16311
+ const activeLine = this.cursorSet.getPrimary().head.line;
16312
+ const activeCol = this.cursorSet.getPrimary().head.col;
16313
+ for (let i = 0; i < this._lineElements.length; i++) {
16314
+ this._lineElements[i].classList.toggle('active-line', !hasSelection && i === activeLine);
16315
+ }
16316
+ this._updateDataInfoPanel('@cursor-data', `Ln ${activeLine + 1}, Col ${activeCol + 1}`);
16317
+ }
16318
+ _renderCursors() {
16319
+ if (!this.currentTab)
16320
+ return;
16321
+ this.cursorsLayer.innerHTML = '';
16322
+ for (const sel of this.cursorSet.cursors) {
16323
+ const el = document.createElement('div');
16324
+ el.className = 'cursor';
16325
+ el.innerHTML = '&nbsp;';
16326
+ el.style.left = (sel.head.col * this.charWidth + this.xPadding) + 'px';
16327
+ el.style.top = (sel.head.line * this.lineHeight) + 'px';
16328
+ this.cursorsLayer.appendChild(el);
16329
+ }
16330
+ this._updateActiveLine();
16331
+ }
16332
+ _renderSelections() {
16333
+ if (!this.currentTab)
16334
+ return;
16335
+ this.selectionsLayer.innerHTML = '';
16336
+ for (const sel of this.cursorSet.cursors) {
16337
+ if (selectionIsEmpty(sel))
16338
+ continue;
16339
+ const start = selectionStart(sel);
16340
+ const end = selectionEnd(sel);
16341
+ for (let line = start.line; line <= end.line; line++) {
16342
+ const lineText = this.doc.getLine(line);
16343
+ const fromCol = line === start.line ? start.col : 0;
16344
+ const toCol = line === end.line ? end.col : lineText.length;
16345
+ if (fromCol === toCol)
16346
+ continue;
16347
+ const div = document.createElement('div');
16348
+ div.className = 'lexcodeselection';
16349
+ div.style.top = (line * this.lineHeight) + 'px';
16350
+ div.style.left = (fromCol * this.charWidth + this.xPadding) + 'px';
16351
+ div.style.width = ((toCol - fromCol) * this.charWidth) + 'px';
16352
+ this.selectionsLayer.appendChild(div);
16353
+ }
16354
+ }
16355
+ }
16356
+ _setFocused(focused) {
16357
+ this._focused = focused;
16358
+ if (focused) {
16359
+ this.cursorsLayer.classList.add('show');
16360
+ this.selectionsLayer.classList.add('show');
16361
+ this.selectionsLayer.classList.remove('unfocused');
16362
+ this._startBlinker();
16363
+ }
16364
+ else {
16365
+ this.cursorsLayer.classList.remove('show');
16366
+ if (!this._isSearchBoxActive) {
16367
+ this.selectionsLayer.classList.add('unfocused');
16368
+ }
16369
+ else {
16370
+ this.selectionsLayer.classList.add('show');
16371
+ }
16372
+ this._stopBlinker();
16373
+ }
16374
+ }
16375
+ _startBlinker() {
16376
+ this._stopBlinker();
16377
+ this._cursorVisible = true;
16378
+ this._setCursorVisibility(true);
16379
+ this._blinkerInterval = setInterval(() => {
16380
+ this._cursorVisible = !this._cursorVisible;
16381
+ this._setCursorVisibility(this._cursorVisible);
16382
+ }, this._cursorBlinkRate);
16383
+ }
16384
+ _stopBlinker() {
16385
+ if (this._blinkerInterval !== null) {
16386
+ clearInterval(this._blinkerInterval);
16387
+ this._blinkerInterval = null;
16388
+ }
16389
+ }
16390
+ _resetBlinker() {
16391
+ if (this._focused) {
16392
+ this._startBlinker();
16393
+ }
16394
+ }
16395
+ _setCursorVisibility(visible) {
16396
+ const cursors = this.cursorsLayer.querySelectorAll('.cursor');
16397
+ for (const c of cursors) {
16398
+ c.style.opacity = visible ? '0.6' : '0';
16399
+ }
16400
+ }
16401
+ // Keyboard input events:
16402
+ _onKeyDown(e) {
16403
+ if (!this.currentTab)
16404
+ return;
16405
+ // Ignore events during IME / dead key composition
16406
+ if (this._composing || e.key === 'Dead')
16407
+ return;
16408
+ // Ignore modifier-only presses
16409
+ if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key))
16410
+ return;
16411
+ const ctrl = e.ctrlKey || e.metaKey;
16412
+ const shift = e.shiftKey;
16413
+ const alt = e.altKey;
16414
+ if (this._keyChain) {
16415
+ const chain = this._keyChain;
16416
+ this._keyChain = null;
16417
+ e.preventDefault();
16418
+ if (ctrl && chain === 'k') {
16419
+ switch (e.key.toLowerCase()) {
16420
+ case 'c':
16421
+ this._commentLines();
16422
+ return;
16423
+ case 'u':
16424
+ this._uncommentLines();
16425
+ return;
16426
+ }
16427
+ }
16428
+ return; // Unknown chord, just consume it
16429
+ }
16430
+ if (ctrl) {
16431
+ switch (e.key.toLowerCase()) {
16432
+ case 'a':
16433
+ e.preventDefault();
16434
+ this.cursorSet.selectAll(this.doc);
16435
+ this._afterCursorMove();
16436
+ return;
16437
+ case 'd':
16438
+ e.preventDefault();
16439
+ this._doFindNextOcurrence();
16440
+ return;
16441
+ case 'f':
16442
+ e.preventDefault();
16443
+ this._doOpenSearch();
16444
+ return;
16445
+ case 'g':
16446
+ e.preventDefault();
16447
+ this._doOpenLineSearch();
16448
+ return;
16449
+ case 'k':
16450
+ e.preventDefault();
16451
+ this._keyChain = 'k';
16452
+ return;
16453
+ case 's': // save
16454
+ e.preventDefault();
16455
+ if (this.onSave) {
16456
+ this.onSave(this.getText(), this);
16457
+ }
16458
+ return;
16459
+ case 'z':
16460
+ e.preventDefault();
16461
+ shift ? this._doRedo() : this._doUndo();
16462
+ return;
16463
+ case 'y':
16464
+ e.preventDefault();
16465
+ this._doRedo();
16466
+ return;
16467
+ case 'c':
16468
+ e.preventDefault();
16469
+ this._doCopy();
16470
+ return;
16471
+ case 'x':
16472
+ e.preventDefault();
16473
+ this._doCut();
16474
+ return;
16475
+ case 'v':
16476
+ e.preventDefault();
16477
+ this._doPaste();
16478
+ return;
16479
+ case ' ':
16480
+ e.preventDefault();
16481
+ // Also call user callback if provided
16482
+ if (this.onCtrlSpace) {
16483
+ this.onCtrlSpace(this.getText(), this);
16484
+ }
16485
+ return;
16486
+ }
16487
+ }
16488
+ switch (e.key) {
16489
+ case 'ArrowLeft':
16490
+ e.preventDefault();
16491
+ this._wasPaired = false;
16492
+ if (ctrl)
16493
+ this.cursorSet.moveWordLeft(this.doc, shift);
16494
+ else
16495
+ this.cursorSet.moveLeft(this.doc, shift);
16496
+ this._afterCursorMove();
16497
+ return;
16498
+ case 'ArrowRight':
16499
+ e.preventDefault();
16500
+ this._wasPaired = false;
16501
+ if (ctrl)
16502
+ this.cursorSet.moveWordRight(this.doc, shift);
16503
+ else
16504
+ this.cursorSet.moveRight(this.doc, shift);
16505
+ this._afterCursorMove();
16506
+ return;
16507
+ case 'ArrowUp':
16508
+ e.preventDefault();
16509
+ if (this._isAutoCompleteActive) {
16510
+ const items = this.autocomplete.childNodes;
16511
+ items[this._selectedAutocompleteIndex]?.classList.remove('selected');
16512
+ this._selectedAutocompleteIndex = (this._selectedAutocompleteIndex - 1 + items.length) % items.length;
16513
+ items[this._selectedAutocompleteIndex]?.classList.add('selected');
16514
+ items[this._selectedAutocompleteIndex]?.scrollIntoView({ block: 'nearest' });
16515
+ return;
16516
+ }
16517
+ this._wasPaired = false;
16518
+ if (alt && shift) {
16519
+ this._duplicateLine(-1);
16520
+ return;
16521
+ }
16522
+ if (alt) {
16523
+ this._swapLine(-1);
16524
+ return;
16525
+ }
16526
+ this.cursorSet.moveUp(this.doc, shift);
16527
+ this._afterCursorMove();
16528
+ return;
16529
+ case 'ArrowDown':
16530
+ e.preventDefault();
16531
+ if (this._isAutoCompleteActive) {
16532
+ const items = this.autocomplete.childNodes;
16533
+ items[this._selectedAutocompleteIndex]?.classList.remove('selected');
16534
+ this._selectedAutocompleteIndex = (this._selectedAutocompleteIndex + 1) % items.length;
16535
+ items[this._selectedAutocompleteIndex]?.classList.add('selected');
16536
+ items[this._selectedAutocompleteIndex]?.scrollIntoView({ block: 'nearest' });
16537
+ return;
16538
+ }
16539
+ this._wasPaired = false;
16540
+ if (alt && shift) {
16541
+ this._duplicateLine(1);
16542
+ return;
16543
+ }
16544
+ if (alt) {
16545
+ this._swapLine(1);
16546
+ return;
16547
+ }
16548
+ this.cursorSet.moveDown(this.doc, shift);
16549
+ this._afterCursorMove();
16550
+ return;
16551
+ case 'Home':
16552
+ e.preventDefault();
16553
+ this._wasPaired = false;
16554
+ this.cursorSet.moveToLineStart(this.doc, shift);
16555
+ this._afterCursorMove();
16556
+ return;
16557
+ case 'End':
16558
+ e.preventDefault();
16559
+ this._wasPaired = false;
16560
+ this.cursorSet.moveToLineEnd(this.doc, shift);
16561
+ this._afterCursorMove();
16562
+ return;
16563
+ case 'Escape':
16564
+ e.preventDefault();
16565
+ if (this._isAutoCompleteActive) {
16566
+ this._doHideAutocomplete();
16567
+ return;
16568
+ }
16569
+ if (this._doHideSearch())
16570
+ return;
16571
+ this.cursorSet.removeSecondaryCursors();
16572
+ // Collapse selection
16573
+ const h = this.cursorSet.getPrimary().head;
16574
+ this.cursorSet.set(h.line, h.col);
16575
+ this._afterCursorMove();
16576
+ return;
16577
+ }
16578
+ switch (e.key) {
16579
+ case 'Backspace':
16580
+ e.preventDefault();
16581
+ this._doBackspace(ctrl);
16582
+ return;
16583
+ case 'Delete':
16584
+ e.preventDefault();
16585
+ this._doDelete(ctrl);
16586
+ return;
16587
+ case 'Enter':
16588
+ e.preventDefault();
16589
+ this._doEnter(ctrl);
16590
+ return;
16591
+ case 'Tab':
16592
+ e.preventDefault();
16593
+ this._doTab(shift);
16594
+ return;
16595
+ }
16596
+ if (e.key.length === 1 && !ctrl) {
16597
+ e.preventDefault();
16598
+ this._doInsertChar(e.key);
16599
+ }
16600
+ }
16601
+ _flushIfActionChanged(action) {
16602
+ if (this._lastAction !== action) {
16603
+ this.undoManager.flush(this.cursorSet.getCursorPositions());
16604
+ this._lastAction = action;
16605
+ }
16606
+ }
16607
+ _flushAction() {
16608
+ this.undoManager.flush(this.cursorSet.getCursorPositions());
16609
+ this._lastAction = '';
16610
+ }
16611
+ _doInsertChar(char) {
16612
+ // Enclose selection if applicable
16613
+ if (char in CodeEditor.PAIR_KEYS && this.cursorSet.hasSelection()) {
16614
+ this._encloseSelection(char, CodeEditor.PAIR_KEYS[char]);
16615
+ return;
16616
+ }
16617
+ this._flushIfActionChanged('insert');
16618
+ this._deleteSelectionIfAny();
16619
+ const changedLines = new Set();
16620
+ let paired = false;
16621
+ for (const idx of this.cursorSet.sortedIndicesBottomUp()) {
16622
+ const cursor = this.cursorSet.cursors[idx];
16623
+ const { line, col } = cursor.head;
16624
+ const nextChar = this.doc.getCharAt(line, col);
16625
+ // If we just auto-paired and the next char is the same closing char, skip over it
16626
+ if (this._wasPaired && nextChar === char) {
16627
+ cursor.head = { line, col: col + 1 };
16628
+ cursor.anchor = { ...cursor.head };
16629
+ continue;
16630
+ }
16631
+ const op = this.doc.insert(line, col, char);
16632
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
16633
+ const pairInserted = char in CodeEditor.PAIR_KEYS && (!nextChar || /\s/.test(nextChar));
16634
+ const charsInserted = pairInserted ? 2 : 1;
16635
+ // Auto-pair: insert closing char if next char is whitespace or end of line
16636
+ if (pairInserted) {
16637
+ const closeOp = this.doc.insert(line, col + 1, CodeEditor.PAIR_KEYS[char]);
16638
+ this.undoManager.record(closeOp, this.cursorSet.getCursorPositions());
16639
+ paired = true;
16640
+ }
16641
+ cursor.head = { line, col: col + 1 };
16642
+ cursor.anchor = { ...cursor.head };
16643
+ this.cursorSet.adjustOthers(idx, line, col, charsInserted);
16644
+ changedLines.add(line);
16645
+ }
16646
+ this._wasPaired = paired;
16647
+ for (const line of changedLines)
16648
+ this._updateLine(line);
16649
+ this._afterCursorMove();
16650
+ // Close autocomplete when typing most chars (except word chars which update it)
16651
+ if (/[\w$]/.test(char)) {
16652
+ // Update autocomplete with new partial word
16653
+ this._doOpenAutocomplete();
16654
+ }
16655
+ else {
16656
+ // Non-word char, close autocomplete
16657
+ this._doHideAutocomplete();
16658
+ }
16659
+ }
16660
+ _getAffectedLines() {
16661
+ const cursor = this.cursorSet.getPrimary();
16662
+ const a = cursor.anchor.line;
16663
+ const h = cursor.head.line;
16664
+ return a <= h ? [a, h] : [h, a];
16665
+ }
16666
+ _commentLines() {
16667
+ const token = this.language.lineComment;
16668
+ if (!token)
16669
+ return;
16670
+ const [fromLine, toLine] = this._getAffectedLines();
16671
+ const comment = token + ' ';
16672
+ // Find minimum indentation across affected lines (skip empty lines)
16673
+ let minIndent = Infinity;
16674
+ for (let i = fromLine; i <= toLine; i++) {
16675
+ const line = this.doc.getLine(i);
16676
+ if (line.trim().length === 0)
16677
+ continue;
16678
+ minIndent = Math.min(minIndent, this.doc.getIndent(i));
16679
+ }
16680
+ if (minIndent === Infinity)
16681
+ minIndent = 0;
16682
+ this._flushAction();
16683
+ for (let i = fromLine; i <= toLine; i++) {
16684
+ const line = this.doc.getLine(i);
16685
+ if (line.trim().length === 0)
16686
+ continue;
16687
+ const op = this.doc.insert(i, minIndent, comment);
16688
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
16689
+ this._updateLine(i);
16690
+ }
16691
+ this._afterCursorMove();
16692
+ }
16693
+ _uncommentLines() {
16694
+ const token = this.language.lineComment;
16695
+ if (!token)
16696
+ return;
16697
+ const [fromLine, toLine] = this._getAffectedLines();
16698
+ this._flushAction();
16699
+ for (let i = fromLine; i <= toLine; i++) {
16700
+ const line = this.doc.getLine(i);
16701
+ const indent = this.doc.getIndent(i);
16702
+ const rest = line.substring(indent);
16703
+ // Check for "// " or "//"
16704
+ if (rest.startsWith(token + ' ')) {
16705
+ const op = this.doc.delete(i, indent, token.length + 1);
16706
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
16707
+ }
16708
+ else if (rest.startsWith(token)) {
16709
+ const op = this.doc.delete(i, indent, token.length);
16710
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
16711
+ }
16712
+ }
16713
+ this._rebuildLines();
16714
+ this._afterCursorMove();
16715
+ }
16716
+ _doFindNextOcurrence() {
16717
+ const primary = this.cursorSet.getPrimary();
16718
+ // Get the search text: selection or word under cursor
16719
+ let searchText = this.cursorSet.getSelectedText(this.doc);
16720
+ if (!searchText) {
16721
+ const { line, col } = primary.head;
16722
+ const [word, start, end] = this.doc.getWordAt(line, col);
16723
+ if (!word)
16724
+ return;
16725
+ // Select the word under cursor first (first Ctrl+D)
16726
+ primary.anchor = { line, col: start };
16727
+ primary.head = { line, col: end };
16728
+ this._renderCursors();
16729
+ this._renderSelections();
16730
+ return;
16731
+ }
16732
+ const lastCursor = this.cursorSet.cursors[this.cursorSet.cursors.length - 1];
16733
+ const lastEnd = posBefore(lastCursor.anchor, lastCursor.head) ? lastCursor.head : lastCursor.anchor;
16734
+ const match = this.doc.findNext(searchText, lastEnd.line, lastEnd.col);
16735
+ if (!match)
16736
+ return;
16737
+ // Check if this occurrence is already selected by any cursor
16738
+ const alreadySelected = this.cursorSet.cursors.some(sel => {
16739
+ const start = posBefore(sel.anchor, sel.head) ? sel.anchor : sel.head;
16740
+ return start.line === match.line && start.col === match.col;
16741
+ });
16742
+ if (alreadySelected)
16743
+ return;
16744
+ this.cursorSet.cursors.push({
16745
+ anchor: { line: match.line, col: match.col },
16746
+ head: { line: match.line, col: match.col + searchText.length }
16747
+ });
16748
+ this._renderCursors();
16749
+ this._renderSelections();
16750
+ this._scrollCursorIntoView();
16751
+ }
16752
+ _doOpenSearch(clear = false) {
16753
+ if (!this.searchBox)
16754
+ return;
16755
+ this._doHideSearch();
16756
+ LX.addClass(this.searchBox, 'opened');
16757
+ this._isSearchBoxActive = true;
16758
+ const input = this.searchBox.querySelector('input');
16759
+ if (!input)
16760
+ return;
16761
+ if (clear) {
16762
+ input.value = '';
16763
+ }
16764
+ else if (this.cursorSet.hasSelection()) {
16765
+ input.value = this.cursorSet.getSelectedText(this.doc);
16766
+ }
16767
+ input.selectionStart = 0;
16768
+ input.selectionEnd = input.value.length;
16769
+ input.focus();
16770
+ }
16771
+ _doOpenLineSearch() {
16772
+ if (!this.searchLineBox)
16773
+ return;
16774
+ this._doHideSearch();
16775
+ LX.emitSignal('@line-number-range', `Type a line number to go to (from 1 to ${this.doc.lineCount}).`);
16776
+ LX.addClass(this.searchLineBox, 'opened');
16777
+ this._isSearchLineBoxActive = true;
16778
+ const input = this.searchLineBox.querySelector('input');
16779
+ if (!input)
16780
+ return;
16781
+ input.value = ':';
16782
+ input.focus();
16783
+ }
16784
+ /**
16785
+ * Returns true if visibility changed.
16786
+ */
16787
+ _doHideSearch() {
16788
+ if (!this.searchBox || !this.searchLineBox)
16789
+ return false;
16790
+ const active = this._isSearchBoxActive;
16791
+ const activeLine = this._isSearchLineBoxActive;
16792
+ if (active) {
16793
+ this.searchBox.classList.remove('opened');
16794
+ this._isSearchBoxActive = false;
16795
+ this._lastSearchPos = null;
16796
+ }
16797
+ else if (activeLine) {
16798
+ this.searchLineBox.classList.remove('opened');
16799
+ this._isSearchLineBoxActive = false;
16800
+ }
16801
+ return (active != this._isSearchBoxActive) || (activeLine != this._isSearchLineBoxActive);
16802
+ }
16803
+ _doSearch(text, reverse = false, callback, skipAlert = true, forceFocus = true) {
16804
+ text = text ?? this._lastTextFound;
16805
+ if (!text)
16806
+ return;
16807
+ const doc = this.doc;
16808
+ let startLine;
16809
+ let startCol;
16810
+ if (this._lastSearchPos) {
16811
+ startLine = this._lastSearchPos.line;
16812
+ startCol = this._lastSearchPos.col + (reverse ? -text.length : text.length);
16813
+ }
16814
+ else {
16815
+ const cursor = this.cursorSet.getPrimary();
16816
+ startLine = cursor.head.line;
16817
+ startCol = cursor.head.col;
16818
+ }
16819
+ const findInLine = (lineIdx, fromCol) => {
16820
+ let lineText = doc.getLine(lineIdx);
16821
+ let needle = text;
16822
+ if (!this._searchMatchCase) {
16823
+ lineText = lineText.toLowerCase();
16824
+ needle = needle.toLowerCase();
16825
+ }
16826
+ if (reverse) {
16827
+ const sub = lineText.substring(0, fromCol);
16828
+ return sub.lastIndexOf(needle);
16829
+ }
16830
+ else {
16831
+ return lineText.indexOf(needle, fromCol);
16832
+ }
16833
+ };
16834
+ let foundLine = -1;
16835
+ let foundCol = -1;
16836
+ if (reverse) {
16837
+ for (let j = startLine; j >= 0; j--) {
16838
+ const col = findInLine(j, j === startLine ? startCol : doc.getLine(j).length);
16839
+ if (col > -1) {
16840
+ foundLine = j;
16841
+ foundCol = col;
16842
+ break;
16843
+ }
16844
+ }
16845
+ // Wrap around from bottom
16846
+ if (foundLine === -1) {
16847
+ for (let j = doc.lineCount - 1; j > startLine; j--) {
16848
+ const col = findInLine(j, doc.getLine(j).length);
16849
+ if (col > -1) {
16850
+ foundLine = j;
16851
+ foundCol = col;
16852
+ break;
16853
+ }
16854
+ }
16855
+ }
16856
+ }
16857
+ else {
16858
+ for (let j = startLine; j < doc.lineCount; j++) {
16859
+ const col = findInLine(j, j === startLine ? startCol : 0);
16860
+ if (col > -1) {
16861
+ foundLine = j;
16862
+ foundCol = col;
16863
+ break;
16864
+ }
16865
+ }
16866
+ // Wrap around from top
16867
+ if (foundLine === -1) {
16868
+ for (let j = 0; j < startLine; j++) {
16869
+ const col = findInLine(j, 0);
16870
+ if (col > -1) {
16871
+ foundLine = j;
16872
+ foundCol = col;
16873
+ break;
16874
+ }
16875
+ }
16876
+ }
16877
+ }
16878
+ if (foundLine === -1) {
16879
+ if (!skipAlert)
16880
+ alert('No results!');
16881
+ this._lastSearchPos = null;
16882
+ return;
16883
+ }
16884
+ this._lastTextFound = text;
16885
+ this._lastSearchPos = { line: foundLine, col: foundCol };
16886
+ if (callback) {
16887
+ callback(foundCol, foundLine);
16888
+ }
16889
+ else {
16890
+ // Select the found text
16891
+ const primary = this.cursorSet.getPrimary();
16892
+ primary.anchor = { line: foundLine, col: foundCol };
16893
+ primary.head = { line: foundLine, col: foundCol + text.length };
16894
+ this._renderCursors();
16895
+ this._renderSelections();
16896
+ }
16897
+ // Scroll to the match
16898
+ this.codeScroller.scrollTop = Math.max((foundLine - 10) * this.lineHeight, 0);
16899
+ this.codeScroller.scrollLeft = Math.max(foundCol * this.charWidth - this.codeScroller.clientWidth / 2, 0);
16900
+ if (forceFocus) {
16901
+ const input = this.searchBox?.querySelector('input');
16902
+ input?.focus();
16903
+ }
16904
+ }
16905
+ _doGotoLine(lineNumber) {
16906
+ if (Number.isNaN(lineNumber) || lineNumber < 1 || lineNumber > this.doc.lineCount)
16907
+ return;
16908
+ this.cursorSet.set(lineNumber - 1, 0);
16909
+ this._afterCursorMove();
16910
+ }
16911
+ _encloseSelection(open, close) {
16912
+ const cursor = this.cursorSet.getPrimary();
16913
+ const sel = cursor.anchor;
16914
+ const head = cursor.head;
16915
+ // Normalize selection (old invertIfNecessary)
16916
+ const fromLine = sel.line < head.line || (sel.line === head.line && sel.col < head.col) ? sel : head;
16917
+ const toLine = fromLine === sel ? head : sel;
16918
+ // Only single-line selections for now
16919
+ if (fromLine.line !== toLine.line)
16920
+ return;
16921
+ const line = fromLine.line;
16922
+ const from = fromLine.col;
16923
+ const to = toLine.col;
16924
+ this._flushAction();
16925
+ const op1 = this.doc.insert(line, from, open);
16926
+ this.undoManager.record(op1, this.cursorSet.getCursorPositions());
16927
+ const op2 = this.doc.insert(line, to + 1, close);
16928
+ this.undoManager.record(op2, this.cursorSet.getCursorPositions());
16929
+ // Keep selection on the enclosed word (shifted by 1)
16930
+ cursor.anchor = { line, col: from + 1 };
16931
+ cursor.head = { line, col: to + 1 };
16932
+ this._updateLine(line);
16933
+ this._afterCursorMove();
16934
+ }
16935
+ _doBackspace(ctrlKey) {
16936
+ this._flushIfActionChanged('backspace');
16937
+ // If any cursor has a selection, delete selections
16938
+ if (this.cursorSet.hasSelection()) {
16939
+ this._deleteSelectionIfAny();
16940
+ this._rebuildLines();
16941
+ this._afterCursorMove();
16942
+ return;
16943
+ }
16944
+ for (const idx of this.cursorSet.sortedIndicesBottomUp()) {
16945
+ const cursor = this.cursorSet.cursors[idx];
16946
+ const { line, col } = cursor.head;
16947
+ if (line === 0 && col === 0)
16948
+ continue;
16949
+ if (col === 0) {
16950
+ // Merge with previous line
16951
+ const prevLineLen = this.doc.getLine(line - 1).length;
16952
+ const op = this.doc.delete(line - 1, prevLineLen, 1);
16953
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
16954
+ cursor.head = { line: line - 1, col: prevLineLen };
16955
+ cursor.anchor = { ...cursor.head };
16956
+ this.cursorSet.adjustOthers(idx, line, 0, prevLineLen, -1);
16957
+ }
16958
+ else if (ctrlKey) {
16959
+ // Delete word left
16960
+ const [word, from] = this.doc.getWordAt(line, col - 1);
16961
+ const deleteFrom = word.length > 0 ? from : col - 1;
16962
+ const deleteLen = col - deleteFrom;
16963
+ const op = this.doc.delete(line, deleteFrom, deleteLen);
16964
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
16965
+ cursor.head = { line, col: deleteFrom };
16966
+ cursor.anchor = { ...cursor.head };
16967
+ this.cursorSet.adjustOthers(idx, line, col, -deleteLen);
16968
+ }
16969
+ else {
16970
+ const op = this.doc.delete(line, col - 1, 1);
16971
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
16972
+ cursor.head = { line, col: col - 1 };
16973
+ cursor.anchor = { ...cursor.head };
16974
+ this.cursorSet.adjustOthers(idx, line, col, -1);
16975
+ }
16976
+ }
16977
+ this._rebuildLines();
16978
+ this._afterCursorMove();
16979
+ this._doOpenAutocomplete();
16980
+ }
16981
+ _doDelete(ctrlKey) {
16982
+ if (this.cursorSet.hasSelection()) {
16983
+ this._deleteSelectionIfAny();
16984
+ this._rebuildLines();
16985
+ this._afterCursorMove();
16986
+ return;
16987
+ }
16988
+ this._flushIfActionChanged('delete');
16989
+ for (const idx of this.cursorSet.sortedIndicesBottomUp()) {
16990
+ const cursor = this.cursorSet.cursors[idx];
16991
+ const { line, col } = cursor.head;
16992
+ const lineText = this.doc.getLine(line);
16993
+ if (col >= lineText.length && line >= this.doc.lineCount - 1)
16994
+ continue;
16995
+ if (col >= lineText.length) {
16996
+ // Merge with next line
16997
+ const op = this.doc.delete(line, col, 1);
16998
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
16999
+ this.cursorSet.adjustOthers(idx, line, col, 0, -1);
17000
+ }
17001
+ else if (ctrlKey) {
17002
+ // Delete word right
17003
+ const [word, , end] = this.doc.getWordAt(line, col);
17004
+ const deleteLen = word.length > 0 ? end - col : 1;
17005
+ const op = this.doc.delete(line, col, deleteLen);
17006
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
17007
+ this.cursorSet.adjustOthers(idx, line, col, -deleteLen);
17008
+ }
17009
+ else {
17010
+ const op = this.doc.delete(line, col, 1);
17011
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
17012
+ this.cursorSet.adjustOthers(idx, line, col, -1);
17013
+ }
17014
+ }
17015
+ this._rebuildLines();
17016
+ this._afterCursorMove();
17017
+ }
17018
+ _doEnter(shift) {
17019
+ if (this._isAutoCompleteActive) {
17020
+ this._doAutocompleteWord();
17021
+ return;
17022
+ }
17023
+ if (shift && this.onRun) {
17024
+ this.onRun(this.getText(), this);
17025
+ return;
17026
+ }
17027
+ this._deleteSelectionIfAny();
17028
+ this._flushAction();
17029
+ for (const idx of this.cursorSet.sortedIndicesBottomUp()) {
17030
+ const cursor = this.cursorSet.cursors[idx];
17031
+ const { line, col } = cursor.head;
17032
+ const indent = this.doc.getIndent(line);
17033
+ const spaces = ' '.repeat(indent);
17034
+ const op = this.doc.insert(line, col, '\n' + spaces);
17035
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
17036
+ cursor.head = { line: line + 1, col: indent };
17037
+ cursor.anchor = { ...cursor.head };
17038
+ this.cursorSet.adjustOthers(idx, line, col, 0, 1);
17039
+ }
17040
+ this._rebuildLines();
17041
+ this._afterCursorMove();
17042
+ }
17043
+ _doTab(shift) {
17044
+ if (this._isAutoCompleteActive) {
17045
+ this._doAutocompleteWord();
17046
+ return;
17047
+ }
17048
+ this._flushAction();
17049
+ for (const idx of this.cursorSet.sortedIndicesBottomUp()) {
17050
+ const cursor = this.cursorSet.cursors[idx];
17051
+ const { line, col } = cursor.head;
17052
+ if (shift) {
17053
+ // Dedent: remove up to tabSize spaces from start
17054
+ const lineText = this.doc.getLine(line);
17055
+ let spacesToRemove = 0;
17056
+ while (spacesToRemove < this.tabSize && spacesToRemove < lineText.length && lineText[spacesToRemove] === ' ') {
17057
+ spacesToRemove++;
17058
+ }
17059
+ if (spacesToRemove > 0) {
17060
+ const op = this.doc.delete(line, 0, spacesToRemove);
17061
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
17062
+ cursor.head = { line, col: Math.max(0, col - spacesToRemove) };
17063
+ cursor.anchor = { ...cursor.head };
17064
+ this.cursorSet.adjustOthers(idx, line, 0, -spacesToRemove);
17065
+ }
17066
+ }
17067
+ else {
17068
+ const spacesToAdd = this.tabSize - (col % this.tabSize);
17069
+ const spaces = ' '.repeat(spacesToAdd);
17070
+ const op = this.doc.insert(line, col, spaces);
17071
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
17072
+ cursor.head = { line, col: col + spacesToAdd };
17073
+ cursor.anchor = { ...cursor.head };
17074
+ this.cursorSet.adjustOthers(idx, line, col, spacesToAdd);
17075
+ }
17076
+ }
17077
+ this._rebuildLines();
17078
+ this._afterCursorMove();
17079
+ }
17080
+ _deleteSelectionIfAny() {
17081
+ let anyDeleted = false;
17082
+ for (const idx of this.cursorSet.sortedIndicesBottomUp()) {
17083
+ const sel = this.cursorSet.cursors[idx];
17084
+ if (selectionIsEmpty(sel))
17085
+ continue;
17086
+ const start = selectionStart(sel);
17087
+ const end = selectionEnd(sel);
17088
+ const selectedText = this.cursorSet.getSelectedText(this.doc, idx);
17089
+ if (!selectedText)
17090
+ continue;
17091
+ const linesRemoved = end.line - start.line;
17092
+ // not exact for multiline, but start.col is where cursor lands
17093
+ const colDelta = linesRemoved === 0 ? (end.col - start.col) : start.col;
17094
+ const op = this.doc.delete(start.line, start.col, selectedText.length);
17095
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
17096
+ sel.head = { ...start };
17097
+ sel.anchor = { ...start };
17098
+ this.cursorSet.adjustOthers(idx, start.line, start.col, -colDelta, -linesRemoved);
17099
+ anyDeleted = true;
17100
+ }
17101
+ if (anyDeleted)
17102
+ this._rebuildLines();
17103
+ }
17104
+ // Clipboard helpers:
17105
+ _doCopy() {
17106
+ const text = this.cursorSet.getSelectedText(this.doc);
17107
+ if (text) {
17108
+ navigator.clipboard.writeText(text);
17109
+ }
17110
+ }
17111
+ _doCut() {
17112
+ this._flushAction();
17113
+ const text = this.cursorSet.getSelectedText(this.doc);
17114
+ if (text) {
17115
+ navigator.clipboard.writeText(text);
17116
+ this._deleteSelectionIfAny();
17117
+ }
17118
+ else {
17119
+ const cursor = this.cursorSet.getPrimary();
17120
+ const line = cursor.head.line;
17121
+ const lineText = this.doc.getLine(line);
17122
+ const isLastLine = line === this.doc.lineCount - 1;
17123
+ navigator.clipboard.writeText(lineText + (isLastLine ? '' : '\n'));
17124
+ const op = this.doc.removeLine(line);
17125
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
17126
+ // Place cursor at col 0 of the resulting line
17127
+ const newLine = Math.min(line, this.doc.lineCount - 1);
17128
+ cursor.head = { line: newLine, col: 0 };
17129
+ cursor.anchor = { ...cursor.head };
17130
+ }
17131
+ this._rebuildLines();
17132
+ this._afterCursorMove();
17133
+ }
17134
+ _swapLine(dir) {
17135
+ const cursor = this.cursorSet.getPrimary();
17136
+ const line = cursor.head.line;
17137
+ const targetLine = line + dir;
17138
+ if (targetLine < 0 || targetLine >= this.doc.lineCount)
17139
+ return;
17140
+ const currentText = this.doc.getLine(line);
17141
+ const targetText = this.doc.getLine(targetLine);
17142
+ this._flushAction();
17143
+ const op1 = this.doc.replaceLine(line, targetText);
17144
+ this.undoManager.record(op1, this.cursorSet.getCursorPositions());
17145
+ const op2 = this.doc.replaceLine(targetLine, currentText);
17146
+ this.undoManager.record(op2, this.cursorSet.getCursorPositions());
17147
+ cursor.head = { line: targetLine, col: cursor.head.col };
17148
+ cursor.anchor = { ...cursor.head };
17149
+ this._rebuildLines();
17150
+ this._afterCursorMove();
17151
+ }
17152
+ _duplicateLine(dir) {
17153
+ const cursor = this.cursorSet.getPrimary();
17154
+ const line = cursor.head.line;
17155
+ const text = this.doc.getLine(line);
17156
+ this._flushAction();
17157
+ const op = this.doc.insertLine(line, text);
17158
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
17159
+ const newLine = dir === 1 ? line + 1 : line;
17160
+ cursor.head = { line: newLine, col: cursor.head.col };
17161
+ cursor.anchor = { ...cursor.head };
17162
+ this._rebuildLines();
17163
+ this._afterCursorMove();
17164
+ }
17165
+ async _doPaste() {
17166
+ const text = await navigator.clipboard.readText();
17167
+ if (!text)
17168
+ return;
17169
+ this._flushAction();
17170
+ this._deleteSelectionIfAny();
17171
+ const cursor = this.cursorSet.getPrimary();
17172
+ const op = this.doc.insert(cursor.head.line, cursor.head.col, text);
17173
+ this.undoManager.record(op, this.cursorSet.getCursorPositions());
17174
+ // Calculate new cursor position after paste
17175
+ const lines = text.split('\n');
17176
+ if (lines.length === 1) {
17177
+ this.cursorSet.set(cursor.head.line, cursor.head.col + text.length);
17178
+ }
17179
+ else {
17180
+ this.cursorSet.set(cursor.head.line + lines.length - 1, lines[lines.length - 1].length);
17181
+ }
17182
+ this._rebuildLines();
17183
+ this._afterCursorMove();
17184
+ }
17185
+ // Undo/Redo:
17186
+ _doUndo() {
17187
+ const result = this.undoManager.undo(this.doc, this.cursorSet.getCursorPositions());
17188
+ if (result) {
17189
+ if (result.cursors.length > 0) {
17190
+ const c = result.cursors[0];
17191
+ this.cursorSet.set(c.line, c.col);
17192
+ }
17193
+ this._rebuildLines();
17194
+ this._afterCursorMove();
17195
+ }
17196
+ }
17197
+ _doRedo() {
17198
+ const result = this.undoManager.redo(this.doc);
17199
+ if (result) {
17200
+ if (result.cursors.length > 0) {
17201
+ const c = result.cursors[0];
17202
+ this.cursorSet.set(c.line, c.col);
17203
+ }
17204
+ this._rebuildLines();
17205
+ this._afterCursorMove();
17206
+ }
17207
+ }
17208
+ // Mouse input events:
17209
+ _onMouseDown(e) {
17210
+ if (!this.currentTab)
17211
+ return;
17212
+ if (this.searchBox && this.searchBox.contains(e.target))
17213
+ return;
17214
+ if (this.autocomplete && this.autocomplete.contains(e.target))
17215
+ return;
17216
+ e.preventDefault(); // Prevent browser from stealing focus from _inputArea
17217
+ this._wasPaired = false;
17218
+ // Calculate line and column from click position
17219
+ const rect = this.codeContainer.getBoundingClientRect();
17220
+ const x = e.clientX - rect.left - this.xPadding;
17221
+ const y = e.clientY - rect.top;
17222
+ const line = LX.clamp(Math.floor(y / this.lineHeight), 0, this.doc.lineCount - 1);
17223
+ const col = LX.clamp(Math.round(x / this.charWidth), 0, this.doc.getLine(line).length);
17224
+ if (e.type === 'contextmenu') {
17225
+ this._onContextMenu(e, line, col);
17226
+ return;
17227
+ }
17228
+ if (e.button !== 0)
17229
+ return;
17230
+ const now = Date.now();
17231
+ if (now - this._lastClickTime < 400 && line === this._lastClickLine) {
17232
+ this._clickCount = Math.min(this._clickCount + 1, 3);
17233
+ }
17234
+ else {
17235
+ this._clickCount = 1;
17236
+ }
17237
+ this._lastClickTime = now;
17238
+ this._lastClickLine = line;
17239
+ // Triple click: select entire line
17240
+ if (this._clickCount === 3) {
17241
+ const sel = this.cursorSet.getPrimary();
17242
+ sel.anchor = { line, col: 0 };
17243
+ sel.head = { line, col: this.doc.getLine(line).length };
17244
+ }
17245
+ // Double click: select word
17246
+ else if (this._clickCount === 2) {
17247
+ const [, start, end] = this.doc.getWordAt(line, col);
17248
+ const sel = this.cursorSet.getPrimary();
17249
+ sel.anchor = { line, col: start };
17250
+ sel.head = { line, col: end };
17251
+ }
17252
+ else if (e.shiftKey) {
17253
+ // Extend selection
17254
+ const sel = this.cursorSet.getPrimary();
17255
+ sel.head = { line, col };
17256
+ }
17257
+ else {
17258
+ this.cursorSet.set(line, col);
17259
+ }
17260
+ this._afterCursorMove();
17261
+ this._inputArea.focus();
17262
+ // Track mouse for drag selection
17263
+ const onMouseMove = (me) => {
17264
+ const mx = me.clientX - rect.left - this.xPadding;
17265
+ const my = me.clientY - rect.top;
17266
+ const ml = Math.max(0, Math.min(Math.floor(my / this.lineHeight), this.doc.lineCount - 1));
17267
+ const mc = Math.max(0, Math.min(Math.round(mx / this.charWidth), this.doc.getLine(ml).length));
17268
+ const sel = this.cursorSet.getPrimary();
17269
+ sel.head = { line: ml, col: mc };
17270
+ this._renderCursors();
17271
+ this._renderSelections();
17272
+ };
17273
+ const onMouseUp = () => {
17274
+ document.removeEventListener('mousemove', onMouseMove);
17275
+ document.removeEventListener('mouseup', onMouseUp);
17276
+ };
17277
+ document.addEventListener('mousemove', onMouseMove);
17278
+ document.addEventListener('mouseup', onMouseUp);
17279
+ }
17280
+ _onContextMenu(e, line, col) {
17281
+ e.preventDefault();
17282
+ // if ( !this.canOpenContextMenu )
17283
+ // {
17284
+ // return;
17285
+ // }
17286
+ const dmOptions = [{ name: 'Copy', icon: 'Copy', callback: () => this._doCopy(), kbd: ['Ctrl', 'C'], useKbdSpecialKeys: false }];
17287
+ if (!this.disableEdition) {
17288
+ dmOptions.push({ name: 'Cut', icon: 'Scissors', callback: () => this._doCut(), kbd: ['Ctrl', 'X'], useKbdSpecialKeys: false });
17289
+ dmOptions.push({ name: 'Paste', icon: 'Paste', callback: () => this._doPaste(), kbd: ['Ctrl', 'V'], useKbdSpecialKeys: false });
17290
+ }
17291
+ if (this.onContextMenu) {
17292
+ const content = this.cursorSet.getSelectedText(this.doc);
17293
+ const options = this.onContextMenu(this, content, e);
17294
+ if (options?.length) {
17295
+ dmOptions.push(null); // Separator
17296
+ for (const o of options) {
17297
+ dmOptions.push({ name: o.path, disabled: o.disabled, callback: o.callback });
17298
+ }
17299
+ }
17300
+ }
17301
+ LX.addDropdownMenu(e.target, dmOptions, { event: e, side: 'bottom', align: 'start' });
17302
+ }
17303
+ // Autocomplete:
17304
+ /**
17305
+ * Get word at cursor position for autocomplete.
17306
+ */
17307
+ _getWordAtCursor() {
17308
+ const cursor = this.cursorSet.getPrimary().head;
17309
+ const line = this.doc.getLine(cursor.line);
17310
+ let start = cursor.col;
17311
+ let end = cursor.col;
17312
+ // Find word boundaries
17313
+ while (start > 0 && /[\w$]/.test(line[start - 1]))
17314
+ start--;
17315
+ while (end < line.length && /[\w$]/.test(line[end]))
17316
+ end++;
17317
+ return { word: line.slice(start, end), start, end };
17318
+ }
17319
+ /**
17320
+ * Open autocomplete box with suggestions from symbols and custom suggestions.
17321
+ */
17322
+ _doOpenAutocomplete() {
17323
+ if (!this.autocomplete || !this.useAutoComplete)
17324
+ return;
17325
+ this.autocomplete.innerHTML = ''; // Clear all suggestions
17326
+ const { word } = this._getWordAtCursor();
17327
+ if (!word || word.length === 0) {
17328
+ this._doHideAutocomplete();
17329
+ return;
17330
+ }
17331
+ const suggestions = [];
17332
+ const added = new Set();
17333
+ const addSuggestion = (label, kind, scope, detail) => {
17334
+ if (!added.has(label)) {
17335
+ suggestions.push({ label, kind, scope, detail });
17336
+ added.add(label);
17337
+ }
17338
+ };
17339
+ // Get first suggestions from symbol table
17340
+ const allSymbols = this.symbolTable.getAllSymbols();
17341
+ for (const symbol of allSymbols) {
17342
+ if (symbol.name.toLowerCase().startsWith(word.toLowerCase())) {
17343
+ addSuggestion(symbol.name, symbol.kind, symbol.scope, `${symbol.kind} in ${symbol.scope}`);
17344
+ }
17345
+ }
17346
+ // Add language reserved keys
17347
+ for (const reservedWord of this.language.reservedWords) {
17348
+ if (reservedWord.toLowerCase().startsWith(word.toLowerCase())) {
17349
+ addSuggestion(reservedWord);
17350
+ }
17351
+ }
17352
+ // Add custom suggestions
17353
+ for (const suggestion of this.customSuggestions) {
17354
+ const label = typeof suggestion === 'string' ? suggestion : suggestion.label;
17355
+ const kind = typeof suggestion === 'object' ? suggestion.kind : undefined;
17356
+ const detail = typeof suggestion === 'object' ? suggestion.detail : undefined;
17357
+ if (label.toLowerCase().startsWith(word.toLowerCase())) {
17358
+ addSuggestion(label, kind, undefined, detail);
17359
+ }
17360
+ }
17361
+ // Close autocomplete if no suggestions
17362
+ if (suggestions.length === 0) {
17363
+ this._doHideAutocomplete();
17364
+ return;
17365
+ }
17366
+ // Sort suggestions: exact matches first, then alphabetically
17367
+ suggestions.sort((a, b) => {
17368
+ const aExact = a.label.toLowerCase() === word.toLowerCase() ? 0 : 1;
17369
+ const bExact = b.label.toLowerCase() === word.toLowerCase() ? 0 : 1;
17370
+ if (aExact !== bExact)
17371
+ return aExact - bExact;
17372
+ return a.label.localeCompare(b.label);
17373
+ });
17374
+ this._selectedAutocompleteIndex = 0;
17375
+ // Render suggestions
17376
+ suggestions.forEach((suggestion, index) => {
17377
+ const item = document.createElement('pre');
17378
+ if (index === this._selectedAutocompleteIndex)
17379
+ item.classList.add('selected');
17380
+ const currSuggestion = suggestion.label;
17381
+ let iconName = 'CaseLower';
17382
+ let iconClass = 'foo';
17383
+ switch (suggestion.kind) {
17384
+ case 'class':
17385
+ iconName = 'CircleNodes';
17386
+ iconClass = 'text-orange-500';
17387
+ break;
17388
+ case 'struct':
17389
+ iconName = 'Form';
17390
+ iconClass = 'text-orange-400';
17391
+ break;
17392
+ case 'interface':
17393
+ iconName = 'FileType';
17394
+ iconClass = 'text-cyan-500';
17395
+ break;
17396
+ case 'enum':
17397
+ iconName = 'ListTree';
17398
+ iconClass = 'text-yellow-500';
17399
+ break;
17400
+ case 'enum-value':
17401
+ iconName = 'Dot';
17402
+ iconClass = 'text-yellow-400';
17403
+ break;
17404
+ case 'type':
17405
+ iconName = 'Type';
17406
+ iconClass = 'text-teal-500';
17407
+ break;
17408
+ case 'function':
17409
+ iconName = 'Function';
17410
+ iconClass = 'text-purple-500';
17411
+ break;
17412
+ case 'method':
17413
+ iconName = 'Box';
17414
+ iconClass = 'text-fuchsia-500';
17415
+ break;
17416
+ case 'variable':
17417
+ iconName = 'Cuboid';
17418
+ iconClass = 'text-blue-400';
17419
+ break;
17420
+ case 'property':
17421
+ iconName = 'Layers';
17422
+ iconClass = 'text-blue-300';
17423
+ break;
17424
+ case 'constructor-call':
17425
+ iconName = 'Hammer';
17426
+ iconClass = 'text-green-500';
17427
+ break;
17428
+ case 'method-call':
17429
+ iconName = 'PlayCircle';
17430
+ iconClass = 'text-gray-400';
17431
+ break;
17432
+ default:
17433
+ iconName = 'CaseLower';
17434
+ iconClass = 'text-gray-500';
17435
+ break;
17436
+ }
17437
+ item.appendChild(LX.makeIcon(iconName, { iconClass: 'ml-1 mr-2', svgClass: 'sm ' + iconClass }));
17438
+ // Highlight the written part
17439
+ const hIndex = currSuggestion.toLowerCase().indexOf(word.toLowerCase());
17440
+ var preWord = document.createElement('span');
17441
+ preWord.textContent = currSuggestion.substring(0, hIndex);
17442
+ item.appendChild(preWord);
17443
+ var actualWord = document.createElement('span');
17444
+ actualWord.textContent = currSuggestion.substring(hIndex, hIndex + word.length);
17445
+ actualWord.classList.add('word-highlight');
17446
+ item.appendChild(actualWord);
17447
+ var postWord = document.createElement('span');
17448
+ postWord.textContent = currSuggestion.substring(hIndex + word.length);
17449
+ item.appendChild(postWord);
17450
+ if (suggestion.kind) {
17451
+ const kind = document.createElement('span');
17452
+ kind.textContent = ` (${suggestion.kind})`;
17453
+ kind.className = 'kind text-muted-foreground text-xs! ml-2';
17454
+ item.appendChild(kind);
17455
+ }
17456
+ item.addEventListener('click', () => {
17457
+ this._doAutocompleteWord();
17458
+ });
17459
+ this.autocomplete.appendChild(item);
17460
+ });
17461
+ this._isAutoCompleteActive = true;
17462
+ const handleClick = (e) => {
17463
+ if (!this.autocomplete?.contains(e.target)) {
17464
+ this._doHideAutocomplete();
17465
+ }
17466
+ };
17467
+ setTimeout(() => document.addEventListener('click', handleClick, { once: true }), 0);
17468
+ // Store cleanup function
17469
+ this.autocomplete._cleanup = () => {
17470
+ document.removeEventListener('click', handleClick);
17471
+ };
17472
+ // Prepare autocomplete ui
17473
+ this.autocomplete.classList.toggle('show', true);
17474
+ this.autocomplete.classList.toggle('no-scrollbar', !(this.autocomplete.scrollHeight > this.autocomplete.offsetHeight));
17475
+ const cursor = this.cursorSet.getPrimary().head;
17476
+ const left = cursor.col * this.charWidth + this.xPadding;
17477
+ const top = (cursor.line + 1) * this.lineHeight + this._cachedTabsHeight - this.codeScroller.scrollTop;
17478
+ this.autocomplete.style.left = left + 'px';
17479
+ this.autocomplete.style.top = top + 'px';
17480
+ }
17481
+ _doHideAutocomplete() {
17482
+ if (!this.autocomplete || !this._isAutoCompleteActive)
17483
+ return;
17484
+ this.autocomplete.innerHTML = ''; // Clear all suggestions
17485
+ this.autocomplete.classList.remove('show');
17486
+ this._isAutoCompleteActive = false;
17487
+ if (this.autocomplete._cleanup) {
17488
+ this.autocomplete._cleanup();
17489
+ delete this.autocomplete._cleanup;
17490
+ }
17491
+ }
17492
+ /**
17493
+ * Insert the selected autocomplete word at cursor.
17494
+ */
17495
+ _doAutocompleteWord() {
17496
+ const word = this._getSelectedAutoCompleteWord();
17497
+ if (!word)
17498
+ return;
17499
+ const cursor = this.cursorSet.getPrimary().head;
17500
+ const { start, end } = this._getWordAtCursor();
17501
+ const line = cursor.line;
17502
+ const cursorsBefore = this.cursorSet.getCursorPositions();
17503
+ if (end > start) {
17504
+ const deleteOp = this.doc.delete(line, start, end - start);
17505
+ this.undoManager.record(deleteOp, cursorsBefore);
17506
+ }
17507
+ const insertOp = this.doc.insert(line, start, word);
17508
+ this.cursorSet.set(line, start + word.length);
17509
+ const cursorsAfter = this.cursorSet.getCursorPositions();
17510
+ this.undoManager.record(insertOp, cursorsAfter);
17511
+ this._rebuildLines();
17512
+ this._afterCursorMove();
17513
+ this._doHideAutocomplete();
17514
+ }
17515
+ _getSelectedAutoCompleteWord() {
17516
+ if (!this.autocomplete || !this._isAutoCompleteActive)
17517
+ return null;
17518
+ const pre = this.autocomplete.childNodes[this._selectedAutocompleteIndex];
17519
+ var word = '';
17520
+ for (let childSpan of pre.childNodes) {
17521
+ const span = childSpan;
17522
+ if (span.constructor != HTMLSpanElement || span.classList.contains('kind')) {
17523
+ continue;
17524
+ }
17525
+ word += span.textContent;
17526
+ }
17527
+ return word;
17528
+ }
17529
+ _afterCursorMove() {
17530
+ this._renderCursors();
17531
+ this._renderSelections();
17532
+ this._resetBlinker();
17533
+ this.resize();
17534
+ this._scrollCursorIntoView();
17535
+ }
17536
+ // Scrollbar & Resize:
17537
+ _scrollCursorIntoView() {
17538
+ const cursor = this.cursorSet.getPrimary().head;
17539
+ const top = cursor.line * this.lineHeight;
17540
+ const left = cursor.col * this.charWidth;
17541
+ // Vertical scroll
17542
+ if (top < this.codeScroller.scrollTop) {
17543
+ this.codeScroller.scrollTop = top;
17544
+ }
17545
+ else if (top + this.lineHeight > this.codeScroller.scrollTop + this.codeScroller.clientHeight) {
17546
+ this.codeScroller.scrollTop = top + this.lineHeight - this.codeScroller.clientHeight;
17547
+ }
17548
+ // Horizontal scroll
17549
+ const sbOffset = ScrollBar.SIZE * 2;
17550
+ if (left < this.codeScroller.scrollLeft) {
17551
+ this.codeScroller.scrollLeft = left;
17552
+ }
17553
+ else if (left + sbOffset > this.codeScroller.scrollLeft + this.codeScroller.clientWidth - this.xPadding) {
17554
+ this.codeScroller.scrollLeft = left + sbOffset - this.codeScroller.clientWidth + this.xPadding;
17555
+ }
17556
+ }
17557
+ _resetGutter() {
17558
+ // Use cached value or compute if not available (e.g., on initial load)
17559
+ const tabsHeight = this._cachedTabsHeight || (this.tabs?.root.getBoundingClientRect().height ?? 0);
17560
+ this.lineGutter.style.height = `calc(100% - ${tabsHeight}px)`;
17561
+ }
17562
+ getMaxLineLength() {
17563
+ if (!this.currentTab)
17564
+ return 0;
17565
+ let max = 0;
17566
+ for (let i = 0; i < this.doc.lineCount; i++) {
17567
+ const len = this.doc.getLine(i).length;
17568
+ if (len > max)
17569
+ max = len;
17570
+ }
17571
+ return max;
17572
+ }
17573
+ resize(force = false) {
17574
+ if (!this.charWidth)
17575
+ return;
17576
+ // Cache layout measurements to avoid reflows
17577
+ this._cachedTabsHeight = this.tabs?.root.getBoundingClientRect().height ?? 0;
17578
+ this._cachedStatusPanelHeight = this.statusPanel?.root.getBoundingClientRect().height ?? 0;
17579
+ const maxLineLength = this.getMaxLineLength();
17580
+ const lineCount = this.currentTab ? this.doc.lineCount : 0;
17581
+ const viewportChars = Math.floor((this.codeScroller.clientWidth - this.xPadding) / this.charWidth);
17582
+ const viewportLines = Math.floor(this.codeScroller.clientHeight / this.lineHeight);
17583
+ let needsHResize = maxLineLength !== this._lastMaxLineLength
17584
+ && (maxLineLength >= viewportChars || this._lastMaxLineLength >= viewportChars);
17585
+ let needsVResize = lineCount !== this._lastLineCount
17586
+ && (lineCount >= viewportLines || this._lastLineCount >= viewportLines);
17587
+ // If doesn't need resize due to not reaching min length, maybe we need to resize if the content shrinks and we have extra space now
17588
+ needsHResize = needsHResize || (maxLineLength < viewportChars && this.hScrollbar?.visible);
17589
+ needsVResize = needsVResize || (lineCount < viewportLines && this.vScrollbar?.visible);
17590
+ if (!force && !needsHResize && !needsVResize)
17591
+ return;
17592
+ this._lastMaxLineLength = maxLineLength;
17593
+ this._lastLineCount = lineCount;
17594
+ if (force || needsHResize) {
17595
+ this.codeSizer.style.minWidth = (maxLineLength * this.charWidth + this.xPadding + ScrollBar.SIZE * 2) + 'px';
17596
+ }
17597
+ if (force || needsVResize) {
17598
+ this.codeSizer.style.minHeight = (lineCount * this.lineHeight + ScrollBar.SIZE * 2) + 'px';
17599
+ }
17600
+ this._resetGutter();
17601
+ setTimeout(() => this._resizeScrollBars(), 10);
17602
+ }
17603
+ _resizeScrollBars() {
17604
+ if (!this.vScrollbar)
17605
+ return;
17606
+ // Use cached offsets to avoid reflows
17607
+ const topOffset = this._cachedTabsHeight;
17608
+ const bottomOffset = this._cachedStatusPanelHeight;
17609
+ // Vertical scrollbar: right edge, between tabs and status bar
17610
+ const scrollHeight = this.codeScroller.scrollHeight;
17611
+ this.vScrollbar.setThumbRatio(scrollHeight > 0 ? this.codeScroller.clientHeight / scrollHeight : 1);
17612
+ this.vScrollbar.root.style.top = topOffset + 'px';
17613
+ this.vScrollbar.root.style.height = `calc(100% - ${topOffset + bottomOffset}px)`;
17614
+ // Horizontal scrollbar: bottom of code area, offset by gutter and vertical scrollbar
17615
+ const scrollWidth = this.codeScroller.scrollWidth;
17616
+ this.hScrollbar.setThumbRatio(scrollWidth > 0 ? this.codeScroller.clientWidth / scrollWidth : 1);
17617
+ this.hScrollbar.root.style.bottom = bottomOffset + 'px';
17618
+ this.hScrollbar.root.style.width = `calc(100% - ${this.xPadding + (this.vScrollbar.visible ? ScrollBar.SIZE : 0)}px)`;
17619
+ }
17620
+ _syncScrollBars() {
17621
+ if (!this.vScrollbar)
17622
+ return;
17623
+ this.vScrollbar.syncToScroll(this.codeScroller.scrollTop, this.codeScroller.scrollHeight - this.codeScroller.clientHeight);
17624
+ this.hScrollbar.syncToScroll(this.codeScroller.scrollLeft, this.codeScroller.scrollWidth - this.codeScroller.clientWidth);
17625
+ }
17626
+ // Files:
17627
+ _doLoadFromFile() {
17628
+ const input = LX.makeElement('input', '', '', document.body);
17629
+ input.type = 'file';
17630
+ input.click();
17631
+ input.addEventListener('change', (e) => {
17632
+ const target = e.target;
17633
+ if (target.files && target.files[0]) {
17634
+ this.loadFile(target.files[0]);
17635
+ }
17636
+ input.remove();
17637
+ });
17638
+ }
17639
+ // Font Size utils:
17640
+ async _setFontSize(size, updateDOM = true) {
17641
+ // Change font size
17642
+ this.fontSize = size;
17643
+ const r = document.querySelector(':root');
17644
+ r.style.setProperty('--code-editor-font-size', `${this.fontSize}px`);
17645
+ window.localStorage.setItem('lexcodeeditor-font-size', `${this.fontSize}`);
17646
+ await this._measureChar();
17647
+ // Change row size
17648
+ const rowPixels = this.fontSize + 6;
17649
+ r.style.setProperty('--code-editor-row-height', `${rowPixels}px`);
17650
+ this.lineHeight = rowPixels;
17651
+ if (updateDOM) {
17652
+ this._rebuildLines();
17653
+ this._afterCursorMove();
17654
+ }
17655
+ // Emit event
17656
+ LX.emitSignal('@font-size', this.fontSize);
17657
+ }
17658
+ _applyFontSizeOffset(offset = 0) {
17659
+ const newFontSize = LX.clamp(this.fontSize + offset, CodeEditor.CODE_MIN_FONT_SIZE, CodeEditor.CODE_MAX_FONT_SIZE);
17660
+ this._setFontSize(newFontSize);
17661
+ }
17662
+ _increaseFontSize() {
17663
+ this._applyFontSizeOffset(1);
17664
+ }
17665
+ _decreaseFontSize() {
17666
+ this._applyFontSizeOffset(-1);
17667
+ }
17668
+ }
17669
+ LX.CodeEditor = CodeEditor;
17670
+
17671
+ // Core.ts @jxarco
17672
+ /**
17673
+ * @method init
17674
+ * @param {Object} options
17675
+ * autoTheme: Use theme depending on browser-system default theme [true]
17676
+ * container: Root location for the gui (default is the document body)
17677
+ * id: Id of the main area
17678
+ * rootClass: Extra class to the root container
17679
+ * skipRoot: Skip adding LX root container
17680
+ * skipDefaultArea: Skip creation of main area
17681
+ * layoutMode: Sets page layout mode (document | app)
17682
+ * spacingMode: Sets page layout spacing mode (default | compact)
17683
+ */
17684
+ LX.init = async function (options = {}) {
17685
+ if (this.ready) {
17686
+ return this.mainArea;
17687
+ }
17688
+ await LX.loadScriptSync('https://unpkg.com/lucide@latest');
17689
+ // LexGUI root
17690
+ console.log(`LexGUI v${this.version}`);
17691
+ const root = LX.makeElement('div', LX.mergeClass('lexcontainer', options.rootClass));
17692
+ root.id = 'lexroot';
17693
+ root.tabIndex = -1;
17694
+ this.modal = LX.makeElement('div', 'inset-0 hidden-opacity bg-black/50 fixed z-100 transition-opacity duration-100 ease-in');
17695
+ this.modal.id = 'modal';
17696
+ this.modal.toggle = function (force) {
17697
+ this.classList.toggle('hidden-opacity', force);
17698
+ };
17699
+ function blockScroll(e) {
17700
+ e.preventDefault();
17701
+ e.stopPropagation();
17702
+ }
17703
+ this.modal.addEventListener('wheel', blockScroll, { passive: false });
17704
+ this.modal.addEventListener('touchmove', blockScroll, { passive: false });
17705
+ this.root = root;
17706
+ this.container = document.body;
17707
+ if (options.container) {
17708
+ this.container = options.container.constructor === String
17709
+ ? document.getElementById(options.container)
17710
+ : options.container;
17711
+ }
17712
+ this.layoutMode = options.layoutMode ?? 'app';
17713
+ document.documentElement.setAttribute('data-layout', this.layoutMode);
17714
+ if (this.layoutMode == 'document') ;
17715
+ this.spacingMode = options.spacingMode ?? 'default';
17716
+ document.documentElement.setAttribute('data-spacing', this.spacingMode);
17717
+ this.container.appendChild(this.modal);
17718
+ if (!options.skipRoot) {
17719
+ this.container.appendChild(root);
17720
+ }
17721
+ else {
17722
+ this.root = document.body;
17723
+ }
17724
+ // Notifications
17725
+ {
17726
+ const notifSection = document.createElement('section');
17727
+ notifSection.className = 'notifications';
17728
+ this.notifications = document.createElement('ol');
17729
+ this.notifications.className = 'fixed flex flex-col-reverse m-0 p-0 gap-1 z-1000';
17730
+ this.notifications.iWidth = 0;
17731
+ notifSection.appendChild(this.notifications);
17732
+ document.body.appendChild(notifSection);
17733
+ this.notifications.addEventListener('mouseenter', () => {
17734
+ this.notifications.classList.add('list');
17735
+ });
17736
+ this.notifications.addEventListener('mouseleave', () => {
17737
+ this.notifications.classList.remove('list');
17738
+ });
17739
+ }
17740
+ // Disable drag icon
17741
+ root.addEventListener('dragover', function (e) {
17742
+ e.preventDefault();
17743
+ }, false);
17744
+ document.addEventListener('contextmenu', function (e) {
17745
+ e.preventDefault();
17746
+ }, false);
17747
+ // Global vars
17748
+ this.DEFAULT_NAME_WIDTH = '30%';
17749
+ this.DEFAULT_SPLITBAR_SIZE = 4;
17750
+ this.OPEN_CONTEXTMENU_ENTRY = 'click';
17751
+ this.componentResizeObserver = new ResizeObserver((entries) => {
17752
+ for (const entry of entries) {
17753
+ const c = entry.target?.jsInstance;
17754
+ if (c && c.onResize) {
17755
+ c.onResize(entry.contentRect);
17756
+ }
17757
+ }
17758
+ });
17759
+ this.ready = true;
17760
+ this.menubars = [];
17761
+ this.sidebars = [];
17762
+ this.commandbar = this._createCommandbar(this.container);
17763
+ if (!options.skipRoot && !options.skipDefaultArea) {
17764
+ this.mainArea = new Area({ id: options.id ?? 'mainarea' });
17765
+ }
17766
+ // Initial or automatic changes don't force color scheme
17767
+ // to be stored in localStorage
17768
+ this._onChangeSystemTheme = function (event) {
17769
+ const storedcolorScheme = localStorage.getItem('lxColorScheme');
17770
+ if (storedcolorScheme)
17771
+ return;
17772
+ LX.setMode(event.matches ? 'dark' : 'light', false);
17773
+ };
17774
+ this._mqlPrefersDarkScheme = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
17775
+ const storedcolorScheme = localStorage.getItem('lxColorScheme');
17776
+ if (storedcolorScheme) {
17777
+ LX.setMode(storedcolorScheme);
17778
+ }
17779
+ else if (this._mqlPrefersDarkScheme && (options.autoTheme ?? true)) {
17780
+ if (window.matchMedia('(prefers-color-scheme: light)').matches) {
17781
+ LX.setMode('light', false);
17782
+ }
17783
+ this._mqlPrefersDarkScheme.addEventListener('change', this._onChangeSystemTheme);
17784
+ }
17785
+ // LX.setThemeColor( 'rose' );
17786
+ return this.mainArea;
17787
+ };
17788
+ /**
17789
+ * @method setSpacingMode
17790
+ * @param {String} mode: "default" | "compact"
17791
+ */
17792
+ LX.setSpacingMode = function (mode) {
17793
+ this.spacingMode = mode;
17794
+ document.documentElement.setAttribute('data-spacing', this.spacingMode);
17795
+ };
17796
+ /**
17797
+ * @method setLayoutMode
17798
+ * @param {String} mode: "app" | "document"
17799
+ */
17800
+ LX.setLayoutMode = function (mode) {
17801
+ this.layoutMode = mode;
17802
+ document.documentElement.setAttribute('data-layout', this.layoutMode);
17803
+ };
17804
+ /**
17805
+ * @method addSignal
17806
+ * @param {String} name
17807
+ * @param {Object} obj
17808
+ * @param {Function} callback
17809
+ */
17810
+ LX.addSignal = function (name, obj, callback) {
17811
+ obj[name] = callback;
17812
+ if (!LX.signals[name]) {
17813
+ LX.signals[name] = [];
17814
+ }
17815
+ if (LX.signals[name].indexOf(obj) > -1) {
17816
+ return;
13552
17817
  }
13553
17818
  LX.signals[name].push(obj);
13554
17819
  };
@@ -13737,19 +18002,24 @@ LX._createCommandbar = function (root) {
13737
18002
  const instances = LX.CodeEditor.getInstances();
13738
18003
  if (!instances.length || !instances[0].area.root.offsetHeight)
13739
18004
  return entries;
13740
- const languages = LX.CodeEditor.languages;
13741
- for (let l of Object.keys(languages)) {
18005
+ for (let l of LX.Tokenizer.getRegisteredLanguages()) {
18006
+ const langDef = Tokenizer.getLanguage(l);
18007
+ if (!langDef)
18008
+ continue;
13742
18009
  const key = 'Language: ' + l;
13743
- const icon = instances[0]._getFileIcon(null, languages[l].ext);
13744
- const classes = icon.split(' ');
13745
- let value = LX.makeIcon(classes[0], { svgClass: `${classes.slice(0).join(' ')}` }).innerHTML;
13746
- value += key + " <span class='lang-ext'>(" + languages[l].ext + ')</span>';
18010
+ const icon = langDef?.icon;
18011
+ const iconData = ((icon) => {
18012
+ const data = icon.constructor === String ? icon : Object.values(icon)[0];
18013
+ return icon ? data.split(' ') : [];
18014
+ })(icon);
18015
+ let value = LX.makeIcon(iconData[0], { svgClass: `${iconData.slice(1).join(' ')}` }).innerHTML;
18016
+ value += key + " <span class='lang-ext'>(" + langDef.extensions + ')</span>';
13747
18017
  if (!_filterEntry(key, filter)) {
13748
18018
  continue;
13749
18019
  }
13750
18020
  entries.push({ name: value, callback: () => {
13751
18021
  for (let i of instances) {
13752
- i._changeLanguage(l);
18022
+ i.setLanguage(l);
13753
18023
  }
13754
18024
  } });
13755
18025
  }