lexgui 8.3.1 → 8.4.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.
@@ -12,7 +12,7 @@ const g$2 = globalThis;
12
12
  let LX = g$2.LX;
13
13
  if (!LX) {
14
14
  LX = {
15
- version: '8.3.1',
15
+ version: '8.4.0',
16
16
  ready: false,
17
17
  extensions: [], // Store extensions used
18
18
  extraCommandbarEntries: [], // User specific entries for command bar
@@ -432,6 +432,8 @@ var ComponentType$1;
432
432
  ComponentType[ComponentType["LABEL"] = 39] = "LABEL";
433
433
  ComponentType[ComponentType["BLANK"] = 40] = "BLANK";
434
434
  ComponentType[ComponentType["RATE"] = 41] = "RATE";
435
+ ComponentType[ComponentType["EMPTY"] = 42] = "EMPTY";
436
+ ComponentType[ComponentType["DESCRIPTION"] = 43] = "DESCRIPTION";
435
437
  })(ComponentType$1 || (ComponentType$1 = {}));
436
438
  LX.ComponentType = ComponentType$1;
437
439
  /**
@@ -733,6 +735,9 @@ class Button extends BaseComponent$1 {
733
735
  const realNameWidth = this.root.domName?.style.width ?? '0px';
734
736
  wValue.style.width = `calc( 100% - ${realNameWidth})`;
735
737
  };
738
+ this.onSetDisabled = (disabled) => {
739
+ wValue.disabled = disabled;
740
+ };
736
741
  // In case of swap, set if a change has to be performed
737
742
  this.setState = function (v, skipCallback) {
738
743
  const swapInput = wValue.querySelector('input');
@@ -1470,6 +1475,12 @@ class NumberInput extends BaseComponent$1 {
1470
1475
  const realNameWidth = this.root.domName?.style.width ?? '0px';
1471
1476
  container.style.width = options.inputWidth ?? `calc( 100% - ${realNameWidth})`;
1472
1477
  };
1478
+ this.onSetDisabled = (disabled) => {
1479
+ vecinput.disabled = disabled;
1480
+ const slider = this.root.querySelector('input[type="range"]');
1481
+ if (slider)
1482
+ slider.disabled = disabled;
1483
+ };
1473
1484
  this.setLimits = (newMin, newMax, newStep) => { };
1474
1485
  var container = document.createElement('div');
1475
1486
  container.className = 'lexnumber';
@@ -1500,7 +1511,7 @@ class NumberInput extends BaseComponent$1 {
1500
1511
  if (!options.skipSlider && options.min !== undefined && options.max !== undefined) {
1501
1512
  let sliderBox = LX.makeContainer(['100%', 'auto'], 'z-1 input-box', '', box);
1502
1513
  let slider = document.createElement('input');
1503
- slider.className = 'lexinputslider';
1514
+ slider.className = 'lexinputslider disabled:pointer-events-none disabled:opacity-50';
1504
1515
  slider.min = options.min;
1505
1516
  slider.max = options.max;
1506
1517
  slider.step = options.step ?? 1;
@@ -1637,6 +1648,11 @@ class TextInput extends BaseComponent$1 {
1637
1648
  const realNameWidth = this.root.domName?.style.width ?? '0px';
1638
1649
  container.style.width = options.inputWidth ?? `calc( 100% - ${realNameWidth})`;
1639
1650
  };
1651
+ this.onSetDisabled = (disabled) => {
1652
+ const input = this.root.querySelector('input');
1653
+ if (input)
1654
+ input.disabled = disabled;
1655
+ };
1640
1656
  this.valid = (v, matchField) => {
1641
1657
  v = v ?? this.value();
1642
1658
  if (!options.pattern)
@@ -1796,6 +1812,9 @@ class Select extends BaseComponent$1 {
1796
1812
  const realNameWidth = this.root.domName?.style.width ?? '0px';
1797
1813
  container.style.width = options.inputWidth ?? `calc( 100% - ${realNameWidth})`;
1798
1814
  };
1815
+ this.onSetDisabled = (disabled) => {
1816
+ selectedOption?.setDisabled(disabled);
1817
+ };
1799
1818
  let container = document.createElement('div');
1800
1819
  container.className = 'lexselect';
1801
1820
  this.root.appendChild(container);
@@ -2070,6 +2089,13 @@ class ArrayInput extends BaseComponent$1 {
2070
2089
  this._trigger(new IEvent$1(name, values, event), callback);
2071
2090
  }
2072
2091
  };
2092
+ this.onSetDisabled = (disabled) => {
2093
+ if (this.root.dataset['opened'] == 'true' && disabled) {
2094
+ this.root.dataset['opened'] = false;
2095
+ this.root.querySelector('.lexarrayitems').toggleAttribute('hidden', true);
2096
+ }
2097
+ toggleButton.setDisabled(disabled);
2098
+ };
2073
2099
  // Add open array button
2074
2100
  let container = document.createElement('div');
2075
2101
  container.className = 'lexarray shrink-1 grow-1 ml-4';
@@ -2161,9 +2187,10 @@ class Card extends BaseComponent$1 {
2161
2187
  const container = LX.makeContainer(['100%', 'auto'], 'lexcard max-w-sm flex flex-col gap-4 bg-card border-color rounded-xl py-6', '', this.root);
2162
2188
  if (options.header) {
2163
2189
  const hasAction = options.header.action !== undefined;
2190
+ const actionButtonOptions = options.header.action.options ?? {};
2164
2191
  let header = LX.makeContainer(['100%', 'auto'], `flex ${hasAction ? 'flex-row gap-4' : 'flex-col gap-1'} px-6`, '', container);
2165
2192
  if (hasAction) {
2166
- const actionBtn = new Button(null, options.header.action.name, options.header.action.callback, { buttonClass: 'secondary' });
2193
+ const actionBtn = new Button(null, options.header.action.name, options.header.action.callback, { buttonClass: 'secondary', ...actionButtonOptions });
2167
2194
  header.appendChild(actionBtn.root);
2168
2195
  const titleDescBox = LX.makeContainer(['75%', 'auto'], `flex flex-col gap-1`, '');
2169
2196
  header.prepend(titleDescBox);
@@ -2230,6 +2257,9 @@ class Checkbox extends BaseComponent$1 {
2230
2257
  const realNameWidth = this.root.domName?.style.width ?? '0px';
2231
2258
  container.style.width = options.inputWidth ?? `calc( 100% - ${realNameWidth})`;
2232
2259
  };
2260
+ this.onSetDisabled = (disabled) => {
2261
+ checkbox.disabled = disabled;
2262
+ };
2233
2263
  let container = document.createElement('div');
2234
2264
  container.className = 'flex items-center gap-2 my-0 mx-auto [&_span]:truncate [&_span]:flex-auto-fill';
2235
2265
  this.root.appendChild(container);
@@ -2858,7 +2888,12 @@ class ColorInput extends BaseComponent$1 {
2858
2888
  const realNameWidth = this.root.domName?.style.width ?? '0px';
2859
2889
  container.style.width = `calc( 100% - ${realNameWidth})`;
2860
2890
  };
2861
- var container = document.createElement('span');
2891
+ this.onSetDisabled = (disabled) => {
2892
+ textComponent.setDisabled(disabled);
2893
+ sampleContainer.classList.toggle('pointer-events-none', disabled);
2894
+ sampleContainer.classList.toggle('opacity-50', disabled);
2895
+ };
2896
+ let container = document.createElement('span');
2862
2897
  container.className = 'lexcolor';
2863
2898
  this.root.appendChild(container);
2864
2899
  this.picker = new ColorPicker(value, {
@@ -3022,6 +3057,11 @@ class Counter extends BaseComponent$1 {
3022
3057
  this._trigger(new IEvent$1(name, newValue, event), callback);
3023
3058
  }
3024
3059
  };
3060
+ this.onSetDisabled = (disabled) => {
3061
+ substrButton.setDisabled(disabled);
3062
+ addButton.setDisabled(disabled);
3063
+ input.disabled = disabled;
3064
+ };
3025
3065
  this.count = value;
3026
3066
  const min = options.min ?? 0;
3027
3067
  const max = options.max ?? 100;
@@ -3767,6 +3807,12 @@ class DatePicker extends BaseComponent$1 {
3767
3807
  const realNameWidth = this.root.domName?.style.width ?? '0px';
3768
3808
  container.style.width = `calc( 100% - ${realNameWidth})`;
3769
3809
  };
3810
+ this.onSetDisabled = (disabled) => {
3811
+ const buttons = this.root.querySelectorAll('button');
3812
+ buttons.forEach((b) => {
3813
+ b.disabled = disabled;
3814
+ });
3815
+ };
3770
3816
  const container = LX.makeContainer(['auto', 'auto'], 'lexdate flex flex-row');
3771
3817
  this.root.appendChild(container);
3772
3818
  if (!dateAsRange) {
@@ -4159,6 +4205,52 @@ class Dial extends BaseComponent$1 {
4159
4205
  LX.CanvasDial = CanvasDial;
4160
4206
  LX.Dial = Dial;
4161
4207
 
4208
+ // Empty.ts @jxarco
4209
+ /**
4210
+ * @class Empty
4211
+ * @description Empty Component
4212
+ */
4213
+ class Empty extends BaseComponent$1 {
4214
+ constructor(name, options = {}) {
4215
+ options.hideName = true;
4216
+ super(ComponentType$1.EMPTY, name, null, options);
4217
+ this.root.classList.add('place-content-center');
4218
+ const container = LX.makeContainer(['100%', 'auto'], 'lexcard max-w-sm flex flex-col gap-4 bg-card border-color rounded-xl py-6', '', this.root);
4219
+ if (options.header) {
4220
+ let header = LX.makeContainer(['100%', 'auto'], `flex flex-col gap-4 px-6 items-center`, '', container);
4221
+ if (options.header.icon) {
4222
+ const icon = LX.makeIcon(options.header.icon, { iconClass: 'bg-secondary p-2 rounded-lg!', svgClass: 'lg' });
4223
+ header.appendChild(icon);
4224
+ }
4225
+ else if (options.header.avatar) {
4226
+ const avatar = new LX.Avatar(options.header.avatar);
4227
+ header.appendChild(avatar.root);
4228
+ }
4229
+ if (options.header.title) {
4230
+ LX.makeElement('div', 'text-center text-foreground leading-none font-medium', options.header.title, header);
4231
+ }
4232
+ if (options.header.description) {
4233
+ LX.makeElement('div', 'text-sm text-center text-balance text-muted-foreground', options.header.description, header);
4234
+ }
4235
+ }
4236
+ if (options.actions) {
4237
+ const content = LX.makeContainer(['100%', 'auto'], 'flex flex-row gap-1 px-6 justify-center', '', container);
4238
+ for (let a of options.actions) {
4239
+ const action = new LX.Button(null, a.name, a.callback, { buttonClass: "sm outline", ...a.options });
4240
+ content.appendChild(action.root);
4241
+ }
4242
+ }
4243
+ if (options.footer) {
4244
+ const footer = LX.makeContainer(['100%', 'auto'], 'flex flex-col gap-1 px-6', '', container);
4245
+ const elements = [].concat(options.footer);
4246
+ for (let e of elements) {
4247
+ footer.appendChild(e.root ? e.root : e);
4248
+ }
4249
+ }
4250
+ }
4251
+ }
4252
+ LX.Empty = Empty;
4253
+
4162
4254
  // FileInput.ts @jxarco
4163
4255
  /**
4164
4256
  * @class FileInput
@@ -4174,6 +4266,9 @@ class FileInput extends BaseComponent$1 {
4174
4266
  const realNameWidth = this.root.domName?.style.width ?? '0px';
4175
4267
  input.style.width = `calc( 100% - ${realNameWidth})`;
4176
4268
  };
4269
+ this.onSetDisabled = (disabled) => {
4270
+ input.disabled = disabled;
4271
+ };
4177
4272
  // Create hidden input
4178
4273
  let input = document.createElement('input');
4179
4274
  input.className = 'lexfileinput';
@@ -4381,6 +4476,9 @@ class Layers extends BaseComponent$1 {
4381
4476
  const realNameWidth = this.root.domName?.style.width ?? '0px';
4382
4477
  container.style.width = `calc( 100% - ${realNameWidth})`;
4383
4478
  };
4479
+ this.onSetDisabled = (disabled) => {
4480
+ this.setLayers(value);
4481
+ };
4384
4482
  const container = LX.makeElement('div', 'lexlayers grid', '', this.root);
4385
4483
  const maxBits = options.maxBits ?? 16;
4386
4484
  this.setLayers = (val) => {
@@ -4457,6 +4555,9 @@ class List extends BaseComponent$1 {
4457
4555
  const realNameWidth = this.root.domName?.style.width ?? '0px';
4458
4556
  listContainer.style.width = `calc( 100% - ${realNameWidth})`;
4459
4557
  };
4558
+ this.onSetDisabled = (disabled) => {
4559
+ this._updateValues(values);
4560
+ };
4460
4561
  this._updateValues = (newValues) => {
4461
4562
  values = newValues;
4462
4563
  listContainer.innerHTML = '';
@@ -4924,16 +5025,19 @@ class Map2D extends BaseComponent$1 {
4924
5025
  const realNameWidth = this.root.domName?.style.width ?? '0px';
4925
5026
  container.style.width = `calc( 100% - ${realNameWidth})`;
4926
5027
  };
5028
+ this.onSetDisabled = (disabled) => {
5029
+ openerButton.setDisabled(disabled);
5030
+ };
4927
5031
  var container = document.createElement('div');
4928
5032
  container.className = 'lexmap2d';
4929
5033
  this.root.appendChild(container);
4930
5034
  this.map2d = new CanvasMap2D(points, callback, options);
4931
- const calendarIcon = LX.makeIcon(options.mapIcon ?? 'SquareMousePointer');
4932
- const calendarButton = new Button(null, 'Open Map', () => {
4933
- this._popover = new Popover(calendarButton.root, [this.map2d]);
5035
+ const icon = LX.makeIcon(options.mapIcon ?? 'SquareMousePointer');
5036
+ const openerButton = new Button(null, 'Open Map', () => {
5037
+ this._popover = new Popover(openerButton.root, [this.map2d]);
4934
5038
  }, { buttonClass: `outline justify-between`, disabled: this.disabled });
4935
- calendarButton.root.querySelector('button').appendChild(calendarIcon);
4936
- container.appendChild(calendarButton.root);
5039
+ openerButton.root.querySelector('button').appendChild(icon);
5040
+ container.appendChild(openerButton.root);
4937
5041
  LX.doAsync(this.onResize.bind(this));
4938
5042
  }
4939
5043
  }
@@ -5640,6 +5744,9 @@ class OTPInput extends BaseComponent$1 {
5640
5744
  const realNameWidth = this.root.domName?.style.width ?? '0px';
5641
5745
  container.style.width = `calc( 100% - ${realNameWidth})`;
5642
5746
  };
5747
+ this.onSetDisabled = (disabled) => {
5748
+ _refreshInput(value);
5749
+ };
5643
5750
  const container = document.createElement('div');
5644
5751
  container.className = 'lexotp flex flex-row items-center';
5645
5752
  this.root.appendChild(container);
@@ -5653,7 +5760,7 @@ class OTPInput extends BaseComponent$1 {
5653
5760
  for (let j = 0; j < g.length; ++j) {
5654
5761
  let number = valueString[itemsCount++];
5655
5762
  number = number == 'x' ? '' : number;
5656
- const slotDom = LX.makeContainer(['36px', '30px'], 'lexotpslot border-t-color border-b-color border-l-color px-3 cursor-text select-none font-medium outline-none', number, container);
5763
+ const slotDom = LX.makeContainer(['36px', '30px'], 'lexotpslot content-center border-t-color border-b-color border-l-color px-3 cursor-text select-none font-medium outline-none', number, container);
5657
5764
  slotDom.tabIndex = '1';
5658
5765
  if (this.disabled) {
5659
5766
  slotDom.classList.add('disabled');
@@ -6057,6 +6164,9 @@ class RangeInput extends BaseComponent$1 {
6057
6164
  slider.style.setProperty('--range-fix-max-offset', `${diffMaxOffset}rem`);
6058
6165
  }
6059
6166
  };
6167
+ this.onSetDisabled = (disabled) => {
6168
+ slider.disabled = disabled;
6169
+ };
6060
6170
  const container = document.createElement('div');
6061
6171
  container.className = 'lexrange relative py-3';
6062
6172
  this.root.appendChild(container);
@@ -6165,6 +6275,9 @@ class Rate extends BaseComponent$1 {
6165
6275
  const realNameWidth = this.root.domName?.style.width ?? '0px';
6166
6276
  container.style.width = `calc( 100% - ${realNameWidth})`;
6167
6277
  };
6278
+ this.onSetDisabled = (disabled) => {
6279
+ container.dataset['disabled'] = disabled.toString();
6280
+ };
6168
6281
  const container = document.createElement('div');
6169
6282
  container.className = 'lexrate relative data-[disabled=true]:pointer-events-none';
6170
6283
  container.dataset['disabled'] = this.disabled.toString();
@@ -7308,6 +7421,9 @@ class Tags extends BaseComponent$1 {
7308
7421
  const realNameWidth = this.root.domName?.style.width ?? '0px';
7309
7422
  tagsContainer.style.width = `calc( 100% - ${realNameWidth})`;
7310
7423
  };
7424
+ this.onSetDisabled = (disabled) => {
7425
+ this.generateTags(arrayValue);
7426
+ };
7311
7427
  // Show tags
7312
7428
  const tagsContainer = document.createElement('div');
7313
7429
  tagsContainer.className = 'inline-flex flex-wrap gap-1 bg-card/50 rounded-lg pad-xs [&_input]:w-2/3';
@@ -7376,6 +7492,11 @@ class TextArea extends BaseComponent$1 {
7376
7492
  const realNameWidth = this.root.domName?.style.width ?? '0px';
7377
7493
  container.style.width = options.inputWidth ?? `calc( 100% - ${realNameWidth})`;
7378
7494
  };
7495
+ this.onSetDisabled = (disabled) => {
7496
+ const textarea = this.root.querySelector('textarea');
7497
+ if (textarea)
7498
+ textarea.disabled = disabled;
7499
+ };
7379
7500
  let container = document.createElement('div');
7380
7501
  container.className = 'lextextarea';
7381
7502
  container.style.display = 'flex';
@@ -7493,6 +7614,9 @@ class Toggle extends BaseComponent$1 {
7493
7614
  const realNameWidth = this.root.domName?.style.width ?? '0px';
7494
7615
  container.style.width = options.inputWidth ?? `calc( 100% - ${realNameWidth})`;
7495
7616
  };
7617
+ this.onSetDisabled = (disabled) => {
7618
+ toggle.disabled = disabled;
7619
+ };
7496
7620
  var container = document.createElement('div');
7497
7621
  container.className = 'flex flex-row gap-2 items-center';
7498
7622
  this.root.appendChild(container);
@@ -7562,6 +7686,12 @@ class Vector extends BaseComponent$1 {
7562
7686
  const realNameWidth = this.root.domName?.style.width ?? '0px';
7563
7687
  container.style.width = `calc( 100% - ${realNameWidth})`;
7564
7688
  };
7689
+ this.onSetDisabled = (disabled) => {
7690
+ const inputs = this.root.querySelectorAll('input');
7691
+ inputs.forEach((i) => {
7692
+ i.disabled = disabled;
7693
+ });
7694
+ };
7565
7695
  this.setLimits = (newMin, newMax, newStep) => { };
7566
7696
  const vectorInputs = [];
7567
7697
  var container = document.createElement('div');
@@ -8306,6 +8436,19 @@ let Panel$2 = class Panel {
8306
8436
  component.type = ComponentType$1.LABEL;
8307
8437
  return component;
8308
8438
  }
8439
+ /**
8440
+ * @method addDescription
8441
+ * @param {String} value Information string
8442
+ * @param {Object} options Text options
8443
+ */
8444
+ addDescription(value, options = {}) {
8445
+ options.disabled = true;
8446
+ options.fitHeight = true;
8447
+ options.inputClass = LX.mergeClass('bg-none', options.inputClass);
8448
+ const component = this.addTextArea(null, value, null, options);
8449
+ component.type = ComponentType$1.DESCRIPTION;
8450
+ return component;
8451
+ }
8309
8452
  /**
8310
8453
  * @method addButton
8311
8454
  * @param {String} name Component name
@@ -8344,18 +8487,22 @@ let Panel$2 = class Panel {
8344
8487
  }
8345
8488
  /**
8346
8489
  * @method addCard
8347
- * @param {String} name Card Name
8348
- * @param {Object} options:
8349
- * text: Card text
8350
- * link: Card link
8351
- * title: Card dom title
8352
- * src: url of the image
8353
- * callback (Function): function to call on click
8490
+ * @param {String} name
8491
+ * @param {Object} options
8354
8492
  */
8355
8493
  addCard(name, options = {}) {
8356
8494
  const component = new Card(name, options);
8357
8495
  return this._attachComponent(component);
8358
8496
  }
8497
+ /**
8498
+ * @method addEmpty
8499
+ * @param {String} name
8500
+ * @param {Object} options
8501
+ */
8502
+ addEmpty(name, options = {}) {
8503
+ const component = new Empty(name, options);
8504
+ return this._attachComponent(component);
8505
+ }
8359
8506
  /**
8360
8507
  * @method addForm
8361
8508
  * @param {String} name Component name
@@ -11129,47 +11276,67 @@ LX.makeContainer = makeContainer;
11129
11276
  * offsetY: Tooltip margin vertical offset
11130
11277
  * active: Tooltip active by default [true]
11131
11278
  * callback: Callback function to execute when the tooltip is shown
11279
+ * delay: Interest delay in ms until showing the tooltip [100]
11132
11280
  */
11133
11281
  function asTooltip(trigger, content, options = {}) {
11134
11282
  console.assert(trigger, 'You need a trigger to generate a tooltip!');
11135
11283
  trigger.dataset['disableTooltip'] = !(options.active ?? true);
11136
11284
  let tooltipDom = null;
11285
+ let delayTimer = null;
11286
+ let rafId = null;
11137
11287
  const _offset = options.offset;
11138
11288
  const _offsetX = options.offsetX ?? (_offset ?? 0);
11139
11289
  const _offsetY = options.offsetY ?? (_offset ?? 6);
11140
- trigger.addEventListener('mouseenter', function (e) {
11141
- if (trigger.dataset['disableTooltip'] == 'true') {
11290
+ const _delay = options.delay ?? 100;
11291
+ const _cleanup = () => {
11292
+ clearTimeout(delayTimer);
11293
+ if (rafId !== null) {
11294
+ cancelAnimationFrame(rafId);
11295
+ rafId = null;
11296
+ }
11297
+ if (tooltipDom) {
11298
+ tooltipDom.remove();
11299
+ tooltipDom = null;
11300
+ }
11301
+ };
11302
+ const _watchConnection = () => {
11303
+ if (!trigger.isConnected) {
11304
+ _cleanup();
11142
11305
  return;
11143
11306
  }
11307
+ if (tooltipDom)
11308
+ rafId = requestAnimationFrame(_watchConnection);
11309
+ };
11310
+ const _showTooltip = () => {
11144
11311
  tooltipDom = LX.makeElement('div', 'lextooltip fixed bg-secondary-foreground text-secondary text-xs px-2 py-1 rounded-lg pointer-events-none data-closed:opacity-0', trigger.dataset['tooltipContent'] ?? content);
11145
11312
  const nestedDialog = trigger.closest('dialog');
11146
11313
  const tooltipParent = nestedDialog ?? LX.root;
11147
- // Remove other first
11314
+ // Remove others first
11148
11315
  LX.root.querySelectorAll('.lextooltip').forEach((e) => e.remove());
11149
- // Append new tooltip
11150
11316
  tooltipParent.appendChild(tooltipDom);
11317
+ // Watch for trigger being removed from the DOM before mouseleave fires
11318
+ rafId = requestAnimationFrame(_watchConnection);
11151
11319
  LX.doAsync(() => {
11320
+ if (!tooltipDom)
11321
+ return;
11152
11322
  const position = [0, 0];
11153
11323
  const offsetX = parseFloat(trigger.dataset['tooltipOffsetX'] ?? _offsetX);
11154
11324
  const offsetY = parseFloat(trigger.dataset['tooltipOffsetY'] ?? _offsetY);
11155
11325
  const rect = trigger.getBoundingClientRect();
11156
- let alignWidth = true;
11157
- switch (options.side ?? 'top') {
11326
+ const side = options.side ?? 'top';
11327
+ const alignWidth = side === 'top' || side === 'bottom';
11328
+ switch (side) {
11158
11329
  case 'left':
11159
11330
  position[0] += rect.x - tooltipDom.offsetWidth - offsetX;
11160
- alignWidth = false;
11161
11331
  break;
11162
11332
  case 'right':
11163
11333
  position[0] += rect.x + rect.width + offsetX;
11164
- alignWidth = false;
11165
11334
  break;
11166
11335
  case 'top':
11167
11336
  position[1] += rect.y - tooltipDom.offsetHeight - offsetY;
11168
- alignWidth = true;
11169
11337
  break;
11170
11338
  case 'bottom':
11171
11339
  position[1] += rect.y + rect.height + offsetY;
11172
- alignWidth = true;
11173
11340
  break;
11174
11341
  }
11175
11342
  if (alignWidth)
@@ -11180,22 +11347,22 @@ function asTooltip(trigger, content, options = {}) {
11180
11347
  position[0] = LX.clamp(position[0], 0, window.innerWidth - tooltipDom.offsetWidth - 4);
11181
11348
  position[1] = LX.clamp(position[1], 0, window.innerHeight - tooltipDom.offsetHeight - 4);
11182
11349
  if (nestedDialog) {
11183
- let parentRect = tooltipParent.getBoundingClientRect();
11350
+ const parentRect = tooltipParent.getBoundingClientRect();
11184
11351
  position[0] -= parentRect.x;
11185
11352
  position[1] -= parentRect.y;
11186
11353
  }
11187
11354
  tooltipDom.style.left = `${position[0]}px`;
11188
11355
  tooltipDom.style.top = `${position[1]}px`;
11189
- if (options.callback) {
11190
- options.callback(tooltipDom, trigger);
11191
- }
11356
+ options.callback?.(tooltipDom, trigger);
11192
11357
  });
11193
- });
11194
- trigger.addEventListener('mouseleave', function (e) {
11195
- if (tooltipDom) {
11196
- tooltipDom.remove();
11358
+ };
11359
+ trigger.addEventListener('mouseenter', function () {
11360
+ if (trigger.dataset['disableTooltip'] == 'true') {
11361
+ return;
11197
11362
  }
11363
+ delayTimer = setTimeout(_showTooltip, _delay);
11198
11364
  });
11365
+ trigger.addEventListener('mouseleave', _cleanup);
11199
11366
  }
11200
11367
  LX.asTooltip = asTooltip;
11201
11368
  function insertChildAtIndex(parent, child, index = Infinity) {
@@ -13317,7 +13484,9 @@ class Tour {
13317
13484
  // using a fullscreen SVG with "rect" elements
13318
13485
  _generateMask(reference) {
13319
13486
  this.tourContainer.innerHTML = ''; // Clear previous content
13487
+ const scrollTop = document.scrollingElement?.scrollTop ?? 0;
13320
13488
  this.tourMask = LX.makeContainer(['100%', '100%'], 'tour-mask absolute inset-0');
13489
+ this.tourMask.style.top = `${scrollTop}px`;
13321
13490
  this.tourContainer.appendChild(this.tourMask);
13322
13491
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
13323
13492
  svg.style.width = '100%';
@@ -13380,7 +13549,7 @@ class Tour {
13380
13549
  // Reference Highlight
13381
13550
  const refContainer = LX.makeContainer(['0', '0'], 'tour-ref-mask absolute');
13382
13551
  refContainer.style.left = `${boundingX - hOffset - 1}px`;
13383
- refContainer.style.top = `${boundingY - vOffset - 1}px`;
13552
+ refContainer.style.top = `${boundingY - vOffset - 1 + scrollTop}px`;
13384
13553
  refContainer.style.width = `${boundingWidth + hOffset * 2 + 2}px`;
13385
13554
  refContainer.style.height = `${boundingHeight + vOffset * 2 + 2}px`;
13386
13555
  this.tourContainer.appendChild(refContainer);
@@ -14425,12 +14594,12 @@ class CodeDocument {
14425
14594
  getText(separator = '\n') {
14426
14595
  return this._lines.join(separator);
14427
14596
  }
14428
- setText(text) {
14597
+ setText(text, silent = false) {
14429
14598
  this._lines = text.split(/\r?\n/);
14430
14599
  if (this._lines.length === 0) {
14431
14600
  this._lines = [''];
14432
14601
  }
14433
- if (this.onChange)
14602
+ if (!silent && this.onChange)
14434
14603
  this.onChange(this);
14435
14604
  }
14436
14605
  getCharAt(line, col) {
@@ -14594,6 +14763,7 @@ class UndoManager {
14594
14763
  _lastPushTime = 0;
14595
14764
  _groupThresholdMs;
14596
14765
  _maxSteps;
14766
+ _savedDepth = 0;
14597
14767
  constructor(groupThresholdMs = 2000, maxSteps = 200) {
14598
14768
  this._groupThresholdMs = groupThresholdMs;
14599
14769
  this._maxSteps = maxSteps;
@@ -14665,11 +14835,19 @@ class UndoManager {
14665
14835
  canRedo() {
14666
14836
  return this._redoStack.length > 0;
14667
14837
  }
14838
+ markSaved() {
14839
+ this._flush();
14840
+ this._savedDepth = this._undoStack.length;
14841
+ }
14842
+ isModified() {
14843
+ return this._undoStack.length !== this._savedDepth || this._pendingOps.length > 0;
14844
+ }
14668
14845
  clear() {
14669
14846
  this._undoStack.length = 0;
14670
14847
  this._redoStack.length = 0;
14671
14848
  this._pendingOps.length = 0;
14672
14849
  this._lastPushTime = 0;
14850
+ this._savedDepth = 0;
14673
14851
  }
14674
14852
  _flush() {
14675
14853
  if (this._pendingOps.length === 0)
@@ -14965,6 +15143,49 @@ class CursorSet {
14965
15143
  this.cursors = merged;
14966
15144
  }
14967
15145
  }
15146
+ /**
15147
+ * Strips string literals and single-line comments from a line of code,
15148
+ * leaving only the structural characters (braces, operators, keywords etc).
15149
+ */
15150
+ function stripLiteralsAndComments(line) {
15151
+ let result = '';
15152
+ let i = 0;
15153
+ while (i < line.length) {
15154
+ const ch = line[i];
15155
+ // Remove single-line comment
15156
+ if (ch === '/' && line[i + 1] === '/') {
15157
+ break;
15158
+ }
15159
+ // Block comment (same line): skip to closing */
15160
+ if (ch === '/' && line[i + 1] === '*') {
15161
+ i += 2;
15162
+ while (i < line.length && !(line[i] === '*' && line[i + 1] === '/'))
15163
+ i++;
15164
+ i += 2;
15165
+ continue;
15166
+ }
15167
+ // Remove strings (single, double, template)
15168
+ if (ch === '"' || ch === "'" || ch === '`') {
15169
+ const quote = ch;
15170
+ i++;
15171
+ while (i < line.length) {
15172
+ if (line[i] === '\\') {
15173
+ i += 2;
15174
+ continue;
15175
+ } // escaped char
15176
+ if (line[i] === quote) {
15177
+ i++;
15178
+ break;
15179
+ } // closing quote
15180
+ i++;
15181
+ }
15182
+ continue;
15183
+ }
15184
+ result += ch;
15185
+ i++;
15186
+ }
15187
+ return result;
15188
+ }
14968
15189
  /**
14969
15190
  * Manages code symbols for autocomplete, navigation, and outlining.
14970
15191
  * Incrementally updates as lines change.
@@ -14973,7 +15194,8 @@ class SymbolTable {
14973
15194
  _symbols = new Map(); // name -> symbols[]
14974
15195
  _lineSymbols = []; // [lineNum] -> symbols declared on that line
14975
15196
  _scopeStack = [{ name: 'global', type: 'global', line: 0 }];
14976
- _lineScopes = []; // [lineNum] -> scope stack at that line
15197
+ _lineScopes = []; // [lineNum] -> scope stack at start of that line
15198
+ _lineScopesEnd = []; // [lineNum] -> scope stack at end of that line
14977
15199
  get currentScope() {
14978
15200
  return this._scopeStack[this._scopeStack.length - 1]?.name ?? 'global';
14979
15201
  }
@@ -14983,6 +15205,9 @@ class SymbolTable {
14983
15205
  getScopeAtLine(line) {
14984
15206
  return this._lineScopes[line] ?? [{ name: 'global', type: 'global', line: 0 }];
14985
15207
  }
15208
+ getLineScopeEnd(line) {
15209
+ return this._lineScopesEnd[line] ?? [{ name: 'global', type: 'global', line: 0 }];
15210
+ }
14986
15211
  getSymbols(name) {
14987
15212
  return this._symbols.get(name) ?? [];
14988
15213
  }
@@ -15001,20 +15226,26 @@ class SymbolTable {
15001
15226
  }
15002
15227
  /** Update scope stack for a line (call before parsing symbols) */
15003
15228
  updateScopeForLine(line, lineText) {
15004
- // Track braces to maintain scope stack
15005
- const openBraces = (lineText.match(/\{/g) || []).length;
15006
- const closeBraces = (lineText.match(/\}/g) || []).length;
15007
- // Save current scope for this line
15229
+ if (line === 0) {
15230
+ this._scopeStack = [{ name: 'global', type: 'global', line: 0 }];
15231
+ }
15232
+ else if (this._lineScopesEnd[line - 1]) {
15233
+ this._scopeStack = [...this._lineScopesEnd[line - 1]];
15234
+ }
15235
+ const stripped = stripLiteralsAndComments(lineText);
15236
+ const openBraces = (stripped.match(/\{/g) || []).length;
15237
+ const closeBraces = (stripped.match(/\}/g) || []).length;
15008
15238
  this._lineScopes[line] = [...this._scopeStack];
15009
15239
  // Pop scopes for closing braces
15010
15240
  for (let i = 0; i < closeBraces; i++) {
15011
15241
  if (this._scopeStack.length > 1)
15012
15242
  this._scopeStack.pop();
15013
15243
  }
15014
- // Push scopes for opening braces (will be named by symbol detection)
15244
+ // Push scopes for opening braces (symbol detection will name them later)
15015
15245
  for (let i = 0; i < openBraces; i++) {
15016
15246
  this._scopeStack.push({ name: 'anonymous', type: 'anonymous', line });
15017
15247
  }
15248
+ this._lineScopesEnd[line] = [...this._scopeStack];
15018
15249
  }
15019
15250
  /** Name the most recent anonymous scope (called when detecting class/function) */
15020
15251
  nameCurrentScope(name, type) {
@@ -15058,6 +15289,7 @@ class SymbolTable {
15058
15289
  resetScopes() {
15059
15290
  this._scopeStack = [{ name: 'global', type: 'global', line: 0 }];
15060
15291
  this._lineScopes = [];
15292
+ this._lineScopesEnd = [];
15061
15293
  }
15062
15294
  clear() {
15063
15295
  this._symbols.clear();
@@ -15070,23 +15302,25 @@ class SymbolTable {
15070
15302
  */
15071
15303
  function parseSymbolsFromLine(lineText, tokens, line, symbolTable) {
15072
15304
  const symbols = [];
15073
- const scope = symbolTable.currentScope;
15074
- const scopeType = symbolTable.currentScopeType;
15075
- // Build set of reserved words from tokens (keywords, statements, builtins) to skip when detecting symbols
15305
+ // Use the scope snapshot from the START of this line, not currentScope/currentScopeType
15306
+ // which will reflect state AFTER updateScopeForLine already pushed/popped braces on this line...
15307
+ const lineScopes = symbolTable.getScopeAtLine(line);
15308
+ const lineScope = lineScopes[lineScopes.length - 1];
15309
+ const scope = lineScope?.name ?? 'global';
15310
+ const scopeType = lineScope?.type ?? 'global';
15076
15311
  const reservedWords = new Set();
15077
15312
  for (const token of tokens) {
15078
15313
  if (['keyword', 'statement', 'builtin', 'preprocessor'].includes(token.type)) {
15079
15314
  reservedWords.add(token.value);
15080
15315
  }
15081
15316
  }
15082
- // Track added symbols by name and approximate position to avoid duplicates
15317
+ // Track added symbols by name and approximate position using 5 chars tolerance to avoid duplicates
15083
15318
  const addedSymbols = new Set();
15084
15319
  const addSymbol = (name, kind, col = 0) => {
15085
15320
  if (!name || !name.match(/^[a-zA-Z_$][\w$]*$/))
15086
15321
  return; // Valid identifier check
15087
15322
  if (reservedWords.has(name))
15088
15323
  return;
15089
- // Unique key using 5 chars tolerance
15090
15324
  const posKey = `${name}@${Math.floor(col / 5) * 5}`;
15091
15325
  if (addedSymbols.has(posKey))
15092
15326
  return; // Already added
@@ -15102,13 +15336,14 @@ function parseSymbolsFromLine(lineText, tokens, line, symbolTable) {
15102
15336
  { regex: /^\s*type\s+([A-Z_]\w*)\s*=/i, kind: 'type' },
15103
15337
  { regex: /^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)/i, kind: 'function' },
15104
15338
  { regex: /^\s*(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>/i, kind: 'function' },
15105
- { 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' },
15106
- { regex: /^\s*(?:public|private|protected|static|readonly)*\s*(\w+)\s*\([^)]*\)\s*[:{]/i, kind: scopeType === 'class' ? 'method' : 'function' }
15339
+ { regex: /^\s*(?:static\s+|const\s+|virtual\s+|inline\s+|extern\s+|pub\s+|async\s+)*(?!\b(?:await|return|if|else|while|for|switch|case|throw|new|delete|typeof|yield)\b)(\w+[\w\s\*&:<>,]*?)\s+(\w+)\s*\([^)]*\)\s*[{;]/i, kind: 'typed-function' },
15340
+ { regex: /^\s*(?:(?:public|private|protected|static|readonly|async|override)\s+)*(\w+)\s*\([^)]*\)\s*[:{]/i, kind: scopeType === 'class' ? 'method' : 'function' }
15107
15341
  ];
15108
15342
  const multiPatterns = [
15109
15343
  { regex: /(?:const|let|var)\s+(\w+)/gi, kind: 'variable' },
15110
15344
  { regex: /(\w+)\s*:\s*(?:function|[A-Z]\w*)/gi, kind: 'variable' },
15111
15345
  { regex: /this\.(\w+)\s*=/gi, kind: 'property' },
15346
+ { regex: /\b(?:private|protected|public)\s+(?:(?:static|readonly)\s+)*(\w+)\s*[=:;]/gi, kind: 'property' },
15112
15347
  { regex: /new\s+([A-Z]\w+)/gi, kind: 'constructor-call' },
15113
15348
  { regex: /(\w+)\s*\(/gi, kind: 'method-call' }
15114
15349
  ];
@@ -15181,6 +15416,56 @@ LX.Area;
15181
15416
  LX.Panel;
15182
15417
  LX.Tabs;
15183
15418
  LX.NodeTree;
15419
+ const HEX_COLOR_RE = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b/g;
15420
+ const URL_REGEX = /(https?:\/\/[^\s"'<>)\]]+)/g;
15421
+ /**
15422
+ * Returns true if the string token at `idx` in the token list is a module import path.
15423
+ */
15424
+ function isImportPath(tokens, idx) {
15425
+ const isWs = (t) => /^\s+$/.test(t.value);
15426
+ const isImportWord = (t) => t.value === 'require' || t.value === 'import';
15427
+ for (let i = idx - 1; i >= 0; i--) {
15428
+ const t = tokens[i];
15429
+ if (isWs(t))
15430
+ continue;
15431
+ if (t.type === 'keyword' && t.value === 'from')
15432
+ return true;
15433
+ if (isImportWord(t))
15434
+ return true;
15435
+ if (t.type === 'symbol' && t.value === '(') {
15436
+ for (let j = i - 1; j >= 0; j--) {
15437
+ const t2 = tokens[j];
15438
+ if (isWs(t2))
15439
+ continue;
15440
+ if (isImportWord(t2))
15441
+ return true;
15442
+ break;
15443
+ }
15444
+ }
15445
+ break;
15446
+ }
15447
+ return false;
15448
+ }
15449
+ /**
15450
+ * Scans a raw token value for hex color literals and returns HTML with each
15451
+ * color wrapped in a swatch span. Non-color text is HTML-escaped.
15452
+ */
15453
+ function injectColorSpans(raw, lineIndex, colOffset) {
15454
+ HEX_COLOR_RE.lastIndex = 0;
15455
+ let result = '';
15456
+ let lastIndex = 0;
15457
+ let match;
15458
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
15459
+ while ((match = HEX_COLOR_RE.exec(raw)) !== null) {
15460
+ result += esc(raw.slice(lastIndex, match.index));
15461
+ const color = match[0];
15462
+ const col = colOffset + match.index;
15463
+ result += `<span class="code-color" data-color="${color}" data-line="${lineIndex}" data-col="${col}" style="--code-color:${color}"><span class="code-color-swatch"></span>${esc(color)}</span>`;
15464
+ lastIndex = match.index + color.length;
15465
+ }
15466
+ result += esc(raw.slice(lastIndex));
15467
+ return result;
15468
+ }
15184
15469
  // _____ _ _ _____
15185
15470
  // | __|___ ___ ___| | | __ |___ ___
15186
15471
  // |__ | _| _| . | | | __ -| .'| _|
@@ -15203,6 +15488,15 @@ class ScrollBar {
15203
15488
  this.thumb = LX.makeElement('div');
15204
15489
  this.thumb.addEventListener('mousedown', (e) => this._onMouseDown(e));
15205
15490
  this.root.appendChild(this.thumb);
15491
+ this.root.addEventListener('mousedown', (e) => {
15492
+ if (e.target === this.thumb)
15493
+ return;
15494
+ const clickPos = this._vertical ? e.offsetY : e.offsetX;
15495
+ const thumbSize = this._vertical ? this.thumb.offsetHeight : this.thumb.offsetWidth;
15496
+ const delta = (clickPos - thumbSize / 2) - this._thumbPos;
15497
+ this._onDrag?.(delta);
15498
+ this._onMouseDown(e); // continue as drag from new position
15499
+ });
15206
15500
  }
15207
15501
  setThumbRatio(ratio) {
15208
15502
  this._thumbRatio = LX.clamp(ratio, 0, 1);
@@ -15326,10 +15620,18 @@ class CodeEditor {
15326
15620
  onReady;
15327
15621
  onCreateFile;
15328
15622
  onCodeChange;
15623
+ onOpenPath;
15624
+ onHoverSymbol;
15329
15625
  _inputArea;
15330
15626
  // State:
15331
15627
  _lineStates = []; // tokenizer state at end of each line
15332
15628
  _lineElements = []; // <pre> element per line
15629
+ _bracketOpenLine = -1; // line of the { opening current scope
15630
+ _bracketCloseLine = -1; // line of the } closing current scope
15631
+ _hoverTimer = null;
15632
+ _hoverPopup = null;
15633
+ _hoverWord = '';
15634
+ _colorPopover = null; // active color picker popover
15333
15635
  _openedTabs = {};
15334
15636
  _loadedTabs = {};
15335
15637
  _storedTabs = {};
@@ -15399,6 +15701,8 @@ class CodeEditor {
15399
15701
  this.onSelectTab = options.onSelectTab;
15400
15702
  this.onReady = options.onReady;
15401
15703
  this.onCodeChange = options.onCodeChange;
15704
+ this.onOpenPath = options.onOpenPath;
15705
+ this.onHoverSymbol = options.onHoverSymbol;
15402
15706
  this.language = Tokenizer.getLanguage(this.highlight) ?? Tokenizer.getLanguage('Plain Text');
15403
15707
  this.symbolTable = new SymbolTable();
15404
15708
  // File explorer
@@ -15608,19 +15912,38 @@ class CodeEditor {
15608
15912
  this.codeArea.root.addEventListener('mousedown', this._onMouseDown.bind(this));
15609
15913
  this.codeArea.root.addEventListener('contextmenu', this._onMouseDown.bind(this));
15610
15914
  this.codeArea.root.addEventListener('mouseover', (e) => {
15611
- const link = e.target.closest('.code-link');
15915
+ const target = e.target;
15916
+ const link = target.closest('.code-link');
15612
15917
  if (link && e.ctrlKey)
15613
15918
  link.classList.add('hovered');
15919
+ const path = target.closest('.code-path');
15920
+ if (path && e.ctrlKey)
15921
+ path.classList.add('hovered');
15614
15922
  });
15615
15923
  this.codeArea.root.addEventListener('mouseout', (e) => {
15616
- const link = e.target.closest('.code-link');
15924
+ const target = e.target;
15925
+ const link = target.closest('.code-link');
15617
15926
  if (link)
15618
15927
  link.classList.remove('hovered');
15928
+ const path = target.closest('.code-path');
15929
+ if (path)
15930
+ path.classList.remove('hovered');
15619
15931
  });
15620
15932
  this.codeArea.root.addEventListener('mousemove', (e) => {
15621
- const link = e.target.closest('.code-link');
15933
+ const target = e.target;
15934
+ const link = target.closest('.code-link');
15622
15935
  if (link)
15623
15936
  link.classList.toggle('hovered', e.ctrlKey);
15937
+ const path = target.closest('.code-path');
15938
+ if (path)
15939
+ path.classList.toggle('hovered', e.ctrlKey);
15940
+ this._onCodeAreaMouseMove(e);
15941
+ });
15942
+ this.codeArea.root.addEventListener('mouseleave', () => {
15943
+ this._clearHoverPopup();
15944
+ });
15945
+ this.codeArea.root.addEventListener('click', (e) => {
15946
+ this._onColorSwatchClick(e);
15624
15947
  });
15625
15948
  // Bottom status panel
15626
15949
  this.statusPanel = this._createStatusPanel(options);
@@ -15712,7 +16035,7 @@ class CodeEditor {
15712
16035
  setText(text, language, detectLang = false) {
15713
16036
  if (!this.currentTab)
15714
16037
  return;
15715
- this.doc.setText(text);
16038
+ this.doc.setText(this._normalizeText(text), true);
15716
16039
  this.cursorSet.set(0, 0);
15717
16040
  this.undoManager.clear();
15718
16041
  this._lineStates = [];
@@ -15903,10 +16226,14 @@ class CodeEditor {
15903
16226
  const codeTab = {
15904
16227
  name,
15905
16228
  dom,
15906
- doc: new CodeDocument(this.onCodeChange),
16229
+ doc: new CodeDocument((doc) => {
16230
+ this._setTabModified(name, true);
16231
+ this.onCodeChange?.(doc);
16232
+ }),
15907
16233
  cursorSet: new CursorSet(),
15908
16234
  undoManager: new UndoManager(),
15909
16235
  language: langName,
16236
+ modified: false,
15910
16237
  title: options.title ?? name
15911
16238
  };
15912
16239
  this._openedTabs[name] = codeTab;
@@ -15930,7 +16257,7 @@ class CodeEditor {
15930
16257
  // Move into the sizer..
15931
16258
  this.codeSizer.appendChild(dom);
15932
16259
  if (options.text) {
15933
- codeTab.doc.setText(options.text);
16260
+ codeTab.doc.setText(options.text, true);
15934
16261
  codeTab.cursorSet.set(0, 0);
15935
16262
  codeTab.undoManager.clear();
15936
16263
  this._renderAllLines();
@@ -15991,15 +16318,14 @@ class CodeEditor {
15991
16318
  }
15992
16319
  setCustomSuggestions(suggestions) {
15993
16320
  if (!suggestions || suggestions.constructor !== Array) {
15994
- console.warn('suggestions should be a string array!');
16321
+ console.warn('suggestions should be an array!');
15995
16322
  return;
15996
16323
  }
15997
- this.customSuggestions = suggestions;
16324
+ this.customSuggestions = suggestions.map(s => typeof s === 'string' ? { label: s } : s);
15998
16325
  }
15999
16326
  loadFile(file, options = {}) {
16000
16327
  const onLoad = (text, name) => {
16001
- // Remove Carriage Return in some cases and sub tabs using spaces
16002
- text = text.replaceAll('\r', '').replaceAll(/\t|\\t/g, ' '.repeat(this.tabSize));
16328
+ text = this._normalizeText(text);
16003
16329
  const ext = LX.getExtension(name);
16004
16330
  const lang = options.language ?? (Tokenizer.getLanguage(options.language)
16005
16331
  ?? (Tokenizer.getLanguageByExtension(ext) ?? Tokenizer.getLanguage('Plain Text')));
@@ -16022,7 +16348,7 @@ class CodeEditor {
16022
16348
  title: options.title ?? name,
16023
16349
  language: langName
16024
16350
  });
16025
- this.doc.setText(text);
16351
+ this.doc.setText(text, true);
16026
16352
  this.setLanguage(langName, ext);
16027
16353
  this.cursorSet.set(0, 0);
16028
16354
  this.undoManager.clear();
@@ -16083,7 +16409,7 @@ class CodeEditor {
16083
16409
  language: langName
16084
16410
  });
16085
16411
  if (results.length === 0) {
16086
- this.doc.setText(processedText);
16412
+ this.doc.setText(processedText, true);
16087
16413
  this.setLanguage(langName, ext);
16088
16414
  this.cursorSet.set(0, 0);
16089
16415
  this.undoManager.clear();
@@ -16125,6 +16451,30 @@ class CodeEditor {
16125
16451
  }
16126
16452
  }, 20);
16127
16453
  }
16454
+ _findTabByPath(importPath) {
16455
+ // By now only uses base name
16456
+ const importBase = importPath.split('/').pop().replace(/\.\w+$/, '').toLowerCase();
16457
+ const allNames = new Set([
16458
+ ...Object.keys(this._openedTabs),
16459
+ ...Object.keys(this._loadedTabs),
16460
+ ...Object.keys(this._storedTabs),
16461
+ ]);
16462
+ for (const name of allNames) {
16463
+ const tabBase = name.split('/').pop().replace(/\.\w+$/, '').toLowerCase();
16464
+ if (tabBase === importBase)
16465
+ return name;
16466
+ }
16467
+ return null;
16468
+ }
16469
+ _setTabModified(name, modified) {
16470
+ const tab = this._openedTabs[name];
16471
+ if (!tab || tab.modified === modified)
16472
+ return;
16473
+ tab.modified = modified;
16474
+ const tabEl = this.tabs?.tabDOMs?.[name];
16475
+ if (tabEl)
16476
+ tabEl.toggleAttribute('data-modified', modified);
16477
+ }
16128
16478
  _onSelectTab(isNewTabButton, event, name) {
16129
16479
  if (this.disableEdition) {
16130
16480
  return;
@@ -16350,20 +16700,55 @@ class CodeEditor {
16350
16700
  const lineText = this.doc.getLine(lineIndex);
16351
16701
  const result = Tokenizer.tokenizeLine(lineText, this.language, prevState);
16352
16702
  const langClass = this.language.name.toLowerCase().replace(/[^a-z]/g, '');
16353
- const URL_REGEX = /(https?:\/\/[^\s"'<>)\]]+)/g;
16703
+ // Pre-compute which token index gets the bracket-highlight class
16704
+ let bracketTokenIdx = -1;
16705
+ if (lineIndex === this._bracketOpenLine) {
16706
+ // Last '{' symbol token on this line
16707
+ for (let i = result.tokens.length - 1; i >= 0; i--) {
16708
+ if (result.tokens[i].type === 'symbol' && result.tokens[i].value === '{') {
16709
+ bracketTokenIdx = i;
16710
+ break;
16711
+ }
16712
+ }
16713
+ }
16714
+ else if (lineIndex === this._bracketCloseLine) {
16715
+ // First '}' symbol token on this line
16716
+ for (let i = 0; i < result.tokens.length; i++) {
16717
+ if (result.tokens[i].type === 'symbol' && result.tokens[i].value === '}') {
16718
+ bracketTokenIdx = i;
16719
+ break;
16720
+ }
16721
+ }
16722
+ }
16354
16723
  let html = '';
16355
- for (const token of result.tokens) {
16724
+ let colOffset = 0;
16725
+ for (let ti = 0; ti < result.tokens.length; ti++) {
16726
+ const token = result.tokens[ti];
16356
16727
  const cls = TOKEN_CLASS_MAP[token.type];
16357
- const escaped = token.value
16358
- .replace(/&/g, '&amp;')
16359
- .replace(/</g, '&lt;')
16360
- .replace(/>/g, '&gt;');
16361
- // Wrap URLs in comment tokens with a clickable span
16362
- const content = (token.type === 'comment')
16363
- ? escaped.replace(URL_REGEX, `<span class="code-link" data-url="$1">$1</span>`)
16364
- : escaped;
16728
+ const tokenCol = colOffset;
16729
+ colOffset += token.value.length;
16730
+ // Inject content depending on type of token: color, url, path?
16731
+ let content;
16732
+ if (token.type === 'comment') {
16733
+ const escaped = token.value
16734
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
16735
+ content = escaped.replace(URL_REGEX, `<span class="code-link" data-url="$1">$1</span>`);
16736
+ }
16737
+ else if (token.type === 'string' && isImportPath(result.tokens, ti)) {
16738
+ const inner = token.value.slice(1, -1); // strip surrounding quotes
16739
+ const q = token.value[0];
16740
+ const escapedInner = inner.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
16741
+ content = `${q}<span class="code-path" data-path="${inner}">${escapedInner}</span>${q}`;
16742
+ }
16743
+ else {
16744
+ content = injectColorSpans(token.value, lineIndex, tokenCol);
16745
+ }
16746
+ const bracketClass = ti === bracketTokenIdx ? ' code-bracket-active' : '';
16365
16747
  if (cls) {
16366
- html += `<span class="${cls} ${langClass}">${content}</span>`;
16748
+ html += `<span class="${cls} ${langClass}${bracketClass}">${content}</span>`;
16749
+ }
16750
+ else if (bracketClass) {
16751
+ html += `<span class="${bracketClass.trim()}">${content}</span>`;
16367
16752
  }
16368
16753
  else {
16369
16754
  html += content;
@@ -16444,6 +16829,13 @@ class CodeEditor {
16444
16829
  break;
16445
16830
  }
16446
16831
  }
16832
+ // Propagate/cascade scope updates to subsequent lines until the start-of-line scope stabilizes
16833
+ for (let i = lineIndex + 1; i < this.doc.lineCount; i++) {
16834
+ const oldStartDepth = this.symbolTable.getScopeAtLine(i).length;
16835
+ this.symbolTable.updateScopeForLine(i, this.doc.getLine(i));
16836
+ if (this.symbolTable.getScopeAtLine(i).length === oldStartDepth)
16837
+ break;
16838
+ }
16447
16839
  }
16448
16840
  /**
16449
16841
  * Rebuild line elements after structural changes (insert/delete lines).
@@ -16485,12 +16877,9 @@ class CodeEditor {
16485
16877
  return;
16486
16878
  this.cursorsLayer.innerHTML = '';
16487
16879
  for (const sel of this.cursorSet.cursors) {
16488
- const el = document.createElement('div');
16489
- el.className = 'cursor';
16490
- el.innerHTML = '&nbsp;';
16880
+ const el = LX.makeElement('div', 'cursor', '&nbsp;', this.cursorsLayer);
16491
16881
  el.style.left = (sel.head.col * this.charWidth + this.xPadding) + 'px';
16492
16882
  el.style.top = (sel.head.line * this.lineHeight) + 'px';
16493
- this.cursorsLayer.appendChild(el);
16494
16883
  }
16495
16884
  this._updateActiveLine();
16496
16885
  }
@@ -16507,14 +16896,16 @@ class CodeEditor {
16507
16896
  const lineText = this.doc.getLine(line);
16508
16897
  const fromCol = line === start.line ? start.col : 0;
16509
16898
  const toCol = line === end.line ? end.col : lineText.length;
16510
- if (fromCol === toCol)
16899
+ // Skip only when the selection ends exactly at col 0 of this line
16900
+ if (fromCol === toCol && line === end.line)
16511
16901
  continue;
16512
- const div = document.createElement('div');
16513
- div.className = 'lexcodeselection';
16902
+ const width = fromCol === toCol
16903
+ ? Math.ceil(this.charWidth * 0.5) // minimum width for empty lines
16904
+ : (toCol - fromCol) * this.charWidth;
16905
+ const div = LX.makeElement('div', 'lexcodeselection', '', this.selectionsLayer);
16514
16906
  div.style.top = (line * this.lineHeight) + 'px';
16515
16907
  div.style.left = (fromCol * this.charWidth + this.xPadding) + 'px';
16516
- div.style.width = ((toCol - fromCol) * this.charWidth) + 'px';
16517
- this.selectionsLayer.appendChild(div);
16908
+ div.style.width = width + 'px';
16518
16909
  }
16519
16910
  }
16520
16911
  }
@@ -16619,6 +17010,8 @@ class CodeEditor {
16619
17010
  e.preventDefault();
16620
17011
  if (this.onSave) {
16621
17012
  this.onSave(this.getText(), this);
17013
+ this.undoManager.markSaved();
17014
+ this._setTabModified(this.currentTab.name, false);
16622
17015
  }
16623
17016
  return;
16624
17017
  case 'z':
@@ -16641,6 +17034,17 @@ class CodeEditor {
16641
17034
  e.preventDefault();
16642
17035
  this._doPaste();
16643
17036
  return;
17037
+ case 'home':
17038
+ e.preventDefault();
17039
+ this.cursorSet.set(0, 0);
17040
+ this._afterCursorMove();
17041
+ return;
17042
+ case 'end':
17043
+ e.preventDefault();
17044
+ const lastLine = this.doc.lineCount - 1;
17045
+ this.cursorSet.set(lastLine, this.doc.getLine(lastLine).length);
17046
+ this._afterCursorMove();
17047
+ return;
16644
17048
  case ' ':
16645
17049
  e.preventDefault();
16646
17050
  // Also call user callback if provided
@@ -17372,10 +17776,21 @@ class CodeEditor {
17372
17776
  this._rebuildLines();
17373
17777
  this._afterCursorMove();
17374
17778
  }
17779
+ /**
17780
+ * Normalize external text before inserting into the document:
17781
+ * - Unify line endings to \n
17782
+ * - Replace tab characters with the configured number of spaces
17783
+ */
17784
+ _normalizeText(text) {
17785
+ return text
17786
+ .replace(/\r\n?/g, '\n')
17787
+ .replace(/\t/g, ' '.repeat(this.tabSize));
17788
+ }
17375
17789
  async _doPaste() {
17376
- const text = await navigator.clipboard.readText();
17377
- if (!text)
17790
+ const raw = await navigator.clipboard.readText();
17791
+ if (!raw)
17378
17792
  return;
17793
+ const text = this._normalizeText(raw);
17379
17794
  this._flushAction();
17380
17795
  this._deleteSelectionIfAny();
17381
17796
  const cursor = this.cursorSet.getPrimary();
@@ -17402,6 +17817,8 @@ class CodeEditor {
17402
17817
  }
17403
17818
  this._rebuildLines();
17404
17819
  this._afterCursorMove();
17820
+ if (this.currentTab)
17821
+ this._setTabModified(this.currentTab.name, this.undoManager.isModified());
17405
17822
  }
17406
17823
  }
17407
17824
  _doRedo() {
@@ -17413,6 +17830,8 @@ class CodeEditor {
17413
17830
  }
17414
17831
  this._rebuildLines();
17415
17832
  this._afterCursorMove();
17833
+ if (this.currentTab)
17834
+ this._setTabModified(this.currentTab.name, this.undoManager.isModified());
17416
17835
  }
17417
17836
  }
17418
17837
  // Mouse input events:
@@ -17423,7 +17842,7 @@ class CodeEditor {
17423
17842
  return;
17424
17843
  if (this.autocomplete && this.autocomplete.contains(e.target))
17425
17844
  return;
17426
- // Ctrl+click: open link if cursor is over a code-link span
17845
+ // Ctrl+click: open link or import path
17427
17846
  if (e.ctrlKey && e.button === 0) {
17428
17847
  const target = e.target;
17429
17848
  const link = target.closest('.code-link');
@@ -17431,6 +17850,15 @@ class CodeEditor {
17431
17850
  window.open(link.dataset.url, '_blank');
17432
17851
  return;
17433
17852
  }
17853
+ const pathEl = target.closest('.code-path');
17854
+ if (pathEl?.dataset.path) {
17855
+ const rawPath = pathEl.dataset.path;
17856
+ const tabName = this._findTabByPath(rawPath);
17857
+ if (tabName)
17858
+ this.loadTab(tabName);
17859
+ this.onOpenPath?.(rawPath, this);
17860
+ return;
17861
+ }
17434
17862
  }
17435
17863
  e.preventDefault(); // Prevent browser from stealing focus from _inputArea
17436
17864
  this._wasPaired = false;
@@ -17478,18 +17906,55 @@ class CodeEditor {
17478
17906
  }
17479
17907
  this._afterCursorMove();
17480
17908
  this._inputArea.focus();
17481
- // Track mouse for drag selection
17482
- const onMouseMove = (me) => {
17483
- const mx = me.clientX - rect.left - this.xPadding;
17484
- const my = me.clientY - rect.top;
17485
- const ml = Math.max(0, Math.min(Math.floor(my / this.lineHeight), this.doc.lineCount - 1));
17486
- const mc = Math.max(0, Math.min(Math.round(mx / this.charWidth), this.doc.getLine(ml).length));
17909
+ // Track mouse for drag selection (with auto-scroll when outside editor window/area)
17910
+ let lastMouseX = 0;
17911
+ let lastMouseY = 0;
17912
+ let rafId = null;
17913
+ const updateSelection = () => {
17914
+ const currentRect = this.codeContainer.getBoundingClientRect();
17915
+ const mx = lastMouseX - currentRect.left - this.xPadding;
17916
+ const my = lastMouseY - currentRect.top;
17917
+ const ml = LX.clamp(Math.floor(my / this.lineHeight), 0, this.doc.lineCount - 1);
17918
+ const mc = LX.clamp(Math.round(mx / this.charWidth), 0, this.doc.getLine(ml).length);
17487
17919
  const sel = this.cursorSet.getPrimary();
17488
17920
  sel.head = { line: ml, col: mc };
17489
17921
  this._renderCursors();
17490
17922
  this._renderSelections();
17491
17923
  };
17924
+ const autoScroll = () => {
17925
+ const scrollerRect = this.codeScroller.getBoundingClientRect();
17926
+ const overshootY = lastMouseY < scrollerRect.top ? lastMouseY - scrollerRect.top
17927
+ : lastMouseY > scrollerRect.bottom ? lastMouseY - scrollerRect.bottom : 0;
17928
+ const overshootX = lastMouseX < scrollerRect.left ? lastMouseX - scrollerRect.left
17929
+ : lastMouseX > scrollerRect.right ? lastMouseX - scrollerRect.right : 0;
17930
+ if (overshootY === 0 && overshootX === 0) {
17931
+ rafId = null;
17932
+ return;
17933
+ }
17934
+ const speedY = Math.sign(overshootY) * Math.min(Math.abs(overshootY) * 0.3, 15);
17935
+ const speedX = Math.sign(overshootX) * Math.min(Math.abs(overshootX) * 0.3, 15);
17936
+ this.codeScroller.scrollTop += speedY;
17937
+ this.codeScroller.scrollLeft += speedX;
17938
+ this._syncScrollBars();
17939
+ updateSelection();
17940
+ rafId = requestAnimationFrame(autoScroll);
17941
+ };
17942
+ const onMouseMove = (me) => {
17943
+ lastMouseX = me.clientX;
17944
+ lastMouseY = me.clientY;
17945
+ updateSelection();
17946
+ const scrollerRect = this.codeScroller.getBoundingClientRect();
17947
+ const isOutside = me.clientY < scrollerRect.top || me.clientY > scrollerRect.bottom
17948
+ || me.clientX < scrollerRect.left || me.clientX > scrollerRect.right;
17949
+ if (isOutside && rafId === null) {
17950
+ rafId = requestAnimationFrame(autoScroll);
17951
+ }
17952
+ };
17492
17953
  const onMouseUp = () => {
17954
+ if (rafId !== null) {
17955
+ cancelAnimationFrame(rafId);
17956
+ rafId = null;
17957
+ }
17493
17958
  document.removeEventListener('mousemove', onMouseMove);
17494
17959
  document.removeEventListener('mouseup', onMouseUp);
17495
17960
  };
@@ -17549,47 +18014,51 @@ class CodeEditor {
17549
18014
  }
17550
18015
  const suggestions = [];
17551
18016
  const added = new Set();
17552
- const addSuggestion = (label, kind, scope, detail, insertText) => {
17553
- if (!added.has(label)) {
17554
- suggestions.push({ label, kind, scope, detail, insertText });
17555
- added.add(label);
18017
+ const addSuggestion = (s) => {
18018
+ if (!added.has(s.label)) {
18019
+ suggestions.push(s);
18020
+ added.add(s.label);
17556
18021
  }
17557
18022
  };
18023
+ const filterSuggestion = (suggestion, word) => {
18024
+ const w = word.toLowerCase();
18025
+ if (suggestion.filterText) {
18026
+ return suggestion.filterText.split(' ').some(token => token.toLowerCase().trim().startsWith(w));
18027
+ }
18028
+ return suggestion.label.toLowerCase().startsWith(w);
18029
+ };
17558
18030
  // Get first suggestions from symbol table
17559
18031
  const allSymbols = this.symbolTable.getAllSymbols();
17560
18032
  for (const symbol of allSymbols) {
17561
- if (symbol.name.toLowerCase().startsWith(word.toLowerCase())) {
17562
- addSuggestion(symbol.name, symbol.kind, symbol.scope, `${symbol.kind} in ${symbol.scope}`);
17563
- }
18033
+ const s = { label: symbol.name, kind: symbol.kind, scope: symbol.scope, detail: `${symbol.kind} in ${symbol.scope}` };
18034
+ if (filterSuggestion(s, word))
18035
+ addSuggestion(s);
17564
18036
  }
17565
18037
  // Add language reserved keys
17566
18038
  for (const reservedWord of this.language.reservedWords) {
17567
- if (reservedWord.toLowerCase().startsWith(word.toLowerCase())) {
17568
- addSuggestion(reservedWord);
17569
- }
18039
+ const s = { label: reservedWord };
18040
+ if (filterSuggestion(s, word))
18041
+ addSuggestion(s);
17570
18042
  }
17571
18043
  // Add custom suggestions
17572
18044
  for (const suggestion of this.customSuggestions) {
17573
- const label = typeof suggestion === 'string' ? suggestion : suggestion.label;
17574
- const kind = typeof suggestion === 'object' ? suggestion.kind : undefined;
17575
- const detail = typeof suggestion === 'object' ? suggestion.detail : undefined;
17576
- const insertText = typeof suggestion === 'object' ? suggestion.insertText : suggestion;
17577
- if (label.toLowerCase().startsWith(word.toLowerCase())) {
17578
- addSuggestion(label, kind, undefined, detail, insertText);
17579
- }
18045
+ if (filterSuggestion(suggestion, word))
18046
+ addSuggestion(suggestion);
17580
18047
  }
17581
- // Close autocomplete if no suggestions
17582
18048
  if (suggestions.length === 0) {
17583
18049
  this._doHideAutocomplete();
17584
18050
  return;
17585
18051
  }
17586
- // Sort suggestions: exact matches first, then alphabetically
18052
+ // Sort suggestions: exact matches first, then by sortText (or label if absent)
18053
+ const w = word.toLowerCase();
17587
18054
  suggestions.sort((a, b) => {
17588
- const aExact = a.label.toLowerCase() === word.toLowerCase() ? 0 : 1;
17589
- const bExact = b.label.toLowerCase() === word.toLowerCase() ? 0 : 1;
18055
+ const aKey = (a.sortText ?? a.label).toLowerCase();
18056
+ const bKey = (b.sortText ?? b.label).toLowerCase();
18057
+ const aExact = aKey === w ? 0 : 1;
18058
+ const bExact = bKey === w ? 0 : 1;
17590
18059
  if (aExact !== bExact)
17591
18060
  return aExact - bExact;
17592
- return a.label.localeCompare(b.label);
18061
+ return aKey.localeCompare(bKey);
17593
18062
  });
17594
18063
  this._selectedAutocompleteIndex = 0;
17595
18064
  // Render suggestions
@@ -17598,9 +18067,9 @@ class CodeEditor {
17598
18067
  item.insertText = suggestion.insertText ?? suggestion.label;
17599
18068
  if (index === this._selectedAutocompleteIndex)
17600
18069
  item.classList.add('selected');
17601
- const currSuggestion = suggestion.label;
17602
- let iconName = 'CaseLower';
17603
- let iconClass = 'foo';
18070
+ const currSuggestionLabel = suggestion.label;
18071
+ let iconName = suggestion.icon ?? 'CaseLower';
18072
+ let iconClass = suggestion.iconClass ?? 'text-gray-500';
17604
18073
  switch (suggestion.kind) {
17605
18074
  case 'class':
17606
18075
  iconName = 'CircleNodes';
@@ -17647,26 +18116,22 @@ class CodeEditor {
17647
18116
  iconClass = 'text-green-500';
17648
18117
  break;
17649
18118
  case 'method-call':
17650
- iconName = 'PlayCircle';
18119
+ iconName = 'Parentheses';
17651
18120
  iconClass = 'text-gray-400';
17652
18121
  break;
17653
- default:
17654
- iconName = 'CaseLower';
17655
- iconClass = 'text-gray-500';
17656
- break;
17657
18122
  }
17658
18123
  item.appendChild(LX.makeIcon(iconName, { iconClass: 'ml-1 mr-2', svgClass: 'sm ' + iconClass }));
17659
18124
  // Highlight the written part
17660
- const hIndex = currSuggestion.toLowerCase().indexOf(word.toLowerCase());
18125
+ const hIndex = currSuggestionLabel.toLowerCase().indexOf(word.toLowerCase());
17661
18126
  var preWord = document.createElement('span');
17662
- preWord.textContent = currSuggestion.substring(0, hIndex);
18127
+ preWord.textContent = currSuggestionLabel.substring(0, hIndex);
17663
18128
  item.appendChild(preWord);
17664
18129
  var actualWord = document.createElement('span');
17665
- actualWord.textContent = currSuggestion.substring(hIndex, hIndex + word.length);
18130
+ actualWord.textContent = currSuggestionLabel.substring(hIndex, hIndex + word.length);
17666
18131
  actualWord.classList.add('word-highlight');
17667
18132
  item.appendChild(actualWord);
17668
18133
  var postWord = document.createElement('span');
17669
- postWord.textContent = currSuggestion.substring(hIndex + word.length);
18134
+ postWord.textContent = currSuggestionLabel.substring(hIndex + word.length);
17670
18135
  item.appendChild(postWord);
17671
18136
  if (suggestion.kind) {
17672
18137
  const kind = document.createElement('span');
@@ -17751,6 +18216,340 @@ class CodeEditor {
17751
18216
  this._resetBlinker();
17752
18217
  this.resize();
17753
18218
  this._scrollCursorIntoView();
18219
+ this._updateBracketHighlight();
18220
+ }
18221
+ /**
18222
+ * Returns the scope stack at the exact cursor position (line + column).
18223
+ * Basically starts from getScopeAtLine and then counts real braces up to the cursor column.
18224
+ */
18225
+ _getScopeAtCursor() {
18226
+ const cursor = this.cursorSet.getPrimary().head;
18227
+ const line = cursor.line;
18228
+ const col = cursor.col;
18229
+ const lineText = this.doc.getLine(line);
18230
+ const scopeStack = [...this.symbolTable.getScopeAtLine(line)];
18231
+ let i = 0;
18232
+ let inString = false;
18233
+ let stringCh = '';
18234
+ while (i < col && i < lineText.length) {
18235
+ const ch = lineText[i];
18236
+ if (inString) {
18237
+ if (ch === '\\') {
18238
+ i += 2;
18239
+ continue;
18240
+ }
18241
+ if (ch === stringCh)
18242
+ inString = false;
18243
+ i++;
18244
+ continue;
18245
+ }
18246
+ if (ch === '/' && lineText[i + 1] === '/')
18247
+ break;
18248
+ if (ch === '/' && lineText[i + 1] === '*') {
18249
+ i += 2;
18250
+ while (i < col && !(lineText[i] === '*' && lineText[i + 1] === '/'))
18251
+ i++;
18252
+ i += 2;
18253
+ continue;
18254
+ }
18255
+ if (ch === '"' || ch === "'" || ch === '`') {
18256
+ inString = true;
18257
+ stringCh = ch;
18258
+ i++;
18259
+ continue;
18260
+ }
18261
+ if (ch === '{') {
18262
+ scopeStack.push({ name: 'anonymous', type: 'anonymous', line });
18263
+ }
18264
+ else if (ch === '}' && scopeStack.length > 1) {
18265
+ scopeStack.pop();
18266
+ }
18267
+ i++;
18268
+ }
18269
+ return scopeStack;
18270
+ }
18271
+ _updateBracketHighlight() {
18272
+ const scopes = this._getScopeAtCursor();
18273
+ // Find innermost non-global scope
18274
+ let innermost = null;
18275
+ for (let i = scopes.length - 1; i >= 0; i--) {
18276
+ if (scopes[i].type !== 'global') {
18277
+ innermost = scopes[i];
18278
+ break;
18279
+ }
18280
+ }
18281
+ const prevOpen = this._bracketOpenLine;
18282
+ const prevClose = this._bracketCloseLine;
18283
+ if (!innermost) {
18284
+ this._bracketOpenLine = -1;
18285
+ this._bracketCloseLine = -1;
18286
+ }
18287
+ else {
18288
+ const openLine = innermost.line;
18289
+ const targetDepth = scopes.length; // depth including the innermost scope
18290
+ // Closing line: last line where scope depth >= targetDepth
18291
+ let closeLine = openLine;
18292
+ for (let i = openLine + 1; i < this.doc.lineCount; i++) {
18293
+ if (this.symbolTable.getScopeAtLine(i).length >= targetDepth)
18294
+ closeLine = i;
18295
+ else
18296
+ break;
18297
+ }
18298
+ this._bracketOpenLine = openLine;
18299
+ this._bracketCloseLine = closeLine;
18300
+ }
18301
+ // Re-render only the lines that changed
18302
+ const linesToUpdate = new Set();
18303
+ if (prevOpen !== this._bracketOpenLine) {
18304
+ linesToUpdate.add(prevOpen);
18305
+ linesToUpdate.add(this._bracketOpenLine);
18306
+ }
18307
+ if (prevClose !== this._bracketCloseLine) {
18308
+ linesToUpdate.add(prevClose);
18309
+ linesToUpdate.add(this._bracketCloseLine);
18310
+ }
18311
+ for (const line of linesToUpdate) {
18312
+ if (line >= 0 && line < this.doc.lineCount)
18313
+ this._updateLine(line);
18314
+ }
18315
+ }
18316
+ // Color picker:
18317
+ _onColorSwatchClick(e) {
18318
+ const span = e.target.closest('.code-color');
18319
+ if (!span)
18320
+ return;
18321
+ e.stopPropagation();
18322
+ e.preventDefault();
18323
+ const colorValue = span.dataset.color;
18324
+ const lineIndex = parseInt(span.dataset.line);
18325
+ const colStart = parseInt(span.dataset.col);
18326
+ let currentLen = colorValue.length;
18327
+ if (this._colorPopover) {
18328
+ this._colorPopover.destroy();
18329
+ this._colorPopover = null;
18330
+ }
18331
+ const picker = new LX.ColorPicker(colorValue, {
18332
+ colorModel: 'Hex',
18333
+ onChange: (color) => {
18334
+ // Generate a hex string matching the original length (# + 3/4/6/8 hex chars)
18335
+ const raw = color.hex.replace(/^#/, '');
18336
+ const digits = currentLen - 1;
18337
+ const newHex = '#' + (digits <= 4
18338
+ ? raw.slice(0, digits).padEnd(digits, '0')
18339
+ : raw.slice(0, Math.min(digits, 8)).padEnd(digits, '0'));
18340
+ const lineText = this.doc.getLine(lineIndex);
18341
+ if (lineText.slice(colStart, colStart + currentLen) !== span.dataset.color)
18342
+ return;
18343
+ const delOp = this.doc.delete(lineIndex, colStart, currentLen);
18344
+ this.undoManager.record(delOp, this.cursorSet.getCursorPositions());
18345
+ const insOp = this.doc.insert(lineIndex, colStart, newHex);
18346
+ this.undoManager.record(insOp, this.cursorSet.getCursorPositions());
18347
+ this._updateLine(lineIndex);
18348
+ currentLen = newHex.length;
18349
+ span.dataset.color = newHex;
18350
+ span.dataset.col = String(colStart);
18351
+ span.style.setProperty('--code-color', newHex);
18352
+ }
18353
+ });
18354
+ this._colorPopover = new LX.Popover(span, [picker], { side: 'bottom', align: 'start', sideOffset: 4 });
18355
+ }
18356
+ // Symbol hover:
18357
+ /**
18358
+ * Extracts the parameter list from a function/method declaration line.
18359
+ */
18360
+ _getSymbolParams(symLine) {
18361
+ if (symLine < 0 || symLine >= this.doc.lineCount)
18362
+ return '()';
18363
+ const m = this.doc.getLine(symLine).match(/\(([^)]*)\)/);
18364
+ return m ? `(${m[1].trim()})` : '()';
18365
+ }
18366
+ /**
18367
+ * Starting from the line where a class is defined, scans forward to find
18368
+ * the constructor signature and returns its parameter list.
18369
+ */
18370
+ _findConstructorParams(classLine) {
18371
+ const classDepth = this.symbolTable.getScopeAtLine(classLine).length;
18372
+ const maxScan = Math.min(classLine + 50, this.doc.lineCount);
18373
+ for (let i = classLine + 1; i < maxScan; i++) {
18374
+ if (this.symbolTable.getScopeAtLine(i).length < classDepth + 1)
18375
+ break;
18376
+ const m = this.doc.getLine(i).match(/\bconstructor\s*\(([^)]*)\)/);
18377
+ if (m)
18378
+ return `(${m[1].trim()})`;
18379
+ }
18380
+ return null;
18381
+ }
18382
+ /**
18383
+ * Given multiple symbols with the same name, pick the most likely one for
18384
+ * the hovered position using the available context data.
18385
+ */
18386
+ _pickBestSymbol(symbols, word, tokenType, lineText, hoveredLine) {
18387
+ if (symbols.length === 1)
18388
+ return symbols[0];
18389
+ const curScopes = this.symbolTable.getScopeAtLine(hoveredLine);
18390
+ const curScope = [...curScopes].reverse().find(s => s.type !== 'global')?.name ?? 'global';
18391
+ const wordEsc = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
18392
+ const isNewCall = new RegExp(`\\bnew\\s+${wordEsc}\\b`).test(lineText);
18393
+ const isFuncCall = new RegExp(`\\b${wordEsc}\\s*[(<]`).test(lineText);
18394
+ const isTypeAnno = new RegExp(`(?::\\s*|<\\s*|[,>]\\s*)${wordEsc}\\b`).test(lineText);
18395
+ const typeKinds = new Set(['class', 'interface', 'type', 'enum', 'struct']);
18396
+ const funcKinds = new Set(['function', 'method']);
18397
+ const scored = symbols.map(sym => {
18398
+ let score = 0;
18399
+ // Defined in the current innermost scope
18400
+ if (sym.scope === curScope)
18401
+ score += 5;
18402
+ // Check token type
18403
+ if (tokenType === 'method' && funcKinds.has(sym.kind))
18404
+ score += 3;
18405
+ if (tokenType === 'type' && typeKinds.has(sym.kind))
18406
+ score += 3;
18407
+ // Hovered-line context patterns
18408
+ if (isNewCall && sym.kind === 'constructor-call')
18409
+ score += 20;
18410
+ if (isNewCall && sym.kind === 'class')
18411
+ score += 3;
18412
+ if (isFuncCall && funcKinds.has(sym.kind))
18413
+ score += 3;
18414
+ if (isTypeAnno && typeKinds.has(sym.kind))
18415
+ score += 2;
18416
+ // Validate kind based on symbol declaration line
18417
+ if (sym.line >= 0 && sym.line < this.doc.lineCount) {
18418
+ const declLine = this.doc.getLine(sym.line);
18419
+ if (/\bfunction\b/.test(declLine) && funcKinds.has(sym.kind))
18420
+ score += 4;
18421
+ if (/\bclass\b/.test(declLine) && sym.kind === 'class')
18422
+ score += 4;
18423
+ if (/\binterface\b/.test(declLine) && sym.kind === 'interface')
18424
+ score += 4;
18425
+ if (/\btype\b/.test(declLine) && sym.kind === 'type')
18426
+ score += 4;
18427
+ if (/\benum\b/.test(declLine) && sym.kind === 'enum')
18428
+ score += 4;
18429
+ if (/\b(?:const|let|var)\b/.test(declLine) && sym.kind === 'variable')
18430
+ score += 3;
18431
+ }
18432
+ // Order also by line proximity
18433
+ const dist = Math.abs(sym.line - hoveredLine);
18434
+ score += Math.max(0, 4 - Math.floor(dist / 20));
18435
+ return { sym, score };
18436
+ });
18437
+ scored.sort((a, b) => b.score - a.score);
18438
+ return scored[0].sym;
18439
+ }
18440
+ _clearHoverPopup() {
18441
+ if (this._hoverTimer) {
18442
+ clearTimeout(this._hoverTimer);
18443
+ this._hoverTimer = null;
18444
+ }
18445
+ if (this._hoverPopup) {
18446
+ this._hoverPopup.remove();
18447
+ this._hoverPopup = null;
18448
+ }
18449
+ this._hoverWord = '';
18450
+ }
18451
+ _onCodeAreaMouseMove(e) {
18452
+ if (!this.currentTab)
18453
+ return;
18454
+ // Only show hover when no button is pressed (no dragging)
18455
+ if (e.buttons !== 0) {
18456
+ this._clearHoverPopup();
18457
+ return;
18458
+ }
18459
+ const rect = this.codeContainer.getBoundingClientRect();
18460
+ const x = e.clientX - rect.left - this.xPadding;
18461
+ const y = e.clientY - rect.top;
18462
+ const line = LX.clamp(Math.floor(y / this.lineHeight), 0, this.doc.lineCount - 1);
18463
+ const col = LX.clamp(Math.round(x / this.charWidth), 0, this.doc.getLine(line).length);
18464
+ const [word, wordStart] = this.doc.getWordAt(line, col);
18465
+ if (!word || word === this._hoverWord)
18466
+ return;
18467
+ this._clearHoverPopup();
18468
+ if (!word.trim())
18469
+ return;
18470
+ this._hoverWord = word;
18471
+ this._hoverTimer = setTimeout(() => {
18472
+ this._hoverTimer = null;
18473
+ const prevState = line > 0 ? (this._lineStates[line - 1] ?? Tokenizer.initialState()) : Tokenizer.initialState();
18474
+ const { tokens } = Tokenizer.tokenizeLine(this.doc.getLine(line), this.language, prevState);
18475
+ let tokenType = 'text';
18476
+ let charPos = 0;
18477
+ for (const tok of tokens) {
18478
+ // Use wordStart (not col) so boundary positions like col=wordEnd don't fall into the next token
18479
+ if (wordStart >= charPos && wordStart < charPos + tok.value.length) {
18480
+ tokenType = tok.type;
18481
+ break;
18482
+ }
18483
+ charPos += tok.value.length;
18484
+ }
18485
+ if (tokenType === 'comment' || tokenType === 'string' || tokenType === 'number')
18486
+ return;
18487
+ const symbols = this.symbolTable.getSymbols(word);
18488
+ const info = { word, tokenType, symbols };
18489
+ let userContent;
18490
+ if (this.onHoverSymbol)
18491
+ userContent = this.onHoverSymbol(info, this);
18492
+ // No popup if user explicitly returns null
18493
+ if (userContent === null)
18494
+ return;
18495
+ const hasSymbols = symbols.length > 0;
18496
+ if (!userContent && !hasSymbols)
18497
+ return;
18498
+ const popup = this._hoverPopup = LX.makeElement('div', 'code-hover-popup [&_span]:text-sm');
18499
+ if (userContent) {
18500
+ if (typeof userContent === 'string')
18501
+ popup.innerHTML = userContent;
18502
+ else
18503
+ popup.appendChild(userContent);
18504
+ }
18505
+ else {
18506
+ const sym = this._pickBestSymbol(symbols, word, tokenType, this.doc.getLine(line), line);
18507
+ let kindLabel = sym.kind;
18508
+ let nameLabel = `<span class="font-semibold">${word}</span>`;
18509
+ if (sym.kind === 'constructor-call') {
18510
+ const classSym = this.symbolTable.getSymbols(word).find(s => s.kind === 'class');
18511
+ const params = classSym != null ? (this._findConstructorParams(classSym.line) ?? '()') : '()';
18512
+ kindLabel = 'constructor';
18513
+ nameLabel = `${nameLabel}<span class="text-muted-foreground">${params}</span>`;
18514
+ }
18515
+ else if (sym.kind === 'function') {
18516
+ const params = this._getSymbolParams(sym.line);
18517
+ nameLabel = `${nameLabel}<span class="text-muted-foreground">${params}</span>`;
18518
+ }
18519
+ else if (sym.scope && sym.scope !== 'global' && sym.scope !== 'anonymous') {
18520
+ if (sym.kind === 'property' || sym.kind === 'method') {
18521
+ const scopePrefix = `<span class="text-muted-foreground">${sym.scope}.</span>`;
18522
+ if (sym.kind === 'method') {
18523
+ const params = this._getSymbolParams(sym.line);
18524
+ nameLabel = `${scopePrefix}${nameLabel}<span class="text-muted-foreground">${params}</span>`;
18525
+ }
18526
+ else {
18527
+ nameLabel = `${scopePrefix}${nameLabel}`;
18528
+ }
18529
+ }
18530
+ else if (sym.kind === 'variable') {
18531
+ // Only prefix the scope when the variable is a member (class/interface/struct field)
18532
+ const declScopes = this.symbolTable.getScopeAtLine(sym.line);
18533
+ const scopeEntry = declScopes.find(s => s.name === sym.scope);
18534
+ const memberTypes = new Set(['class', 'interface', 'struct']);
18535
+ if (scopeEntry && memberTypes.has(scopeEntry.type)) {
18536
+ nameLabel = `<span class="text-muted-foreground">${sym.scope}.</span>${nameLabel}`;
18537
+ }
18538
+ }
18539
+ }
18540
+ popup.innerHTML = `<span class="text-info">(${kindLabel})</span> ${nameLabel}`;
18541
+ }
18542
+ document.body.appendChild(popup);
18543
+ // Position just below the hovered word
18544
+ const lineEl = this._lineElements[line];
18545
+ if (lineEl) {
18546
+ const elRect = lineEl.getBoundingClientRect();
18547
+ const leftPx = elRect.left + this.xPadding + col * this.charWidth;
18548
+ const topPx = elRect.bottom + 4;
18549
+ popup.style.left = Math.min(leftPx, window.innerWidth - popup.offsetWidth - 8) + 'px';
18550
+ popup.style.top = topPx + 'px';
18551
+ }
18552
+ }, 500);
17754
18553
  }
17755
18554
  // Scrollbar & Resize:
17756
18555
  _scrollCursorIntoView() {
@@ -18830,7 +19629,7 @@ class AssetView {
18830
19629
  }
18831
19630
  }
18832
19631
  else if (isFolder) {
18833
- that._enterFolder(item);
19632
+ that._requestEnterFolder(item);
18834
19633
  return;
18835
19634
  }
18836
19635
  const onSelect = that._callbacks['select'];
@@ -19048,17 +19847,17 @@ class AssetView {
19048
19847
  if (!this.prevData.length || !this.currentFolder)
19049
19848
  return;
19050
19849
  this.nextData.push(this.currentFolder);
19051
- this._enterFolder(this.prevData.pop(), false);
19850
+ this._enterFolder(this.prevData.pop(), false, true);
19052
19851
  }, { buttonClass: 'ghost', title: 'Go Back', tooltip: true, icon: 'ArrowLeft' });
19053
19852
  panel.addButton(null, 'GoForwardButton', () => {
19054
19853
  if (!this.nextData.length || !this.currentFolder)
19055
19854
  return;
19056
- this._enterFolder(this.nextData.pop());
19855
+ this._enterFolder(this.nextData.pop(), false, true);
19057
19856
  }, { buttonClass: 'ghost', title: 'Go Forward', tooltip: true, icon: 'ArrowRight' });
19058
19857
  panel.addButton(null, 'GoUpButton', () => {
19059
19858
  const parentFolder = this.currentFolder?.parent;
19060
19859
  if (parentFolder)
19061
- this._enterFolder(parentFolder);
19860
+ this._enterFolder(parentFolder, false, true);
19062
19861
  }, { buttonClass: 'ghost', title: 'Go Upper Folder', tooltip: true, icon: 'ArrowUp' });
19063
19862
  panel.addButton(null, 'RefreshButton', () => {
19064
19863
  this._refreshContent(undefined, undefined, true);
@@ -19098,7 +19897,7 @@ class AssetView {
19098
19897
  this._updatePath();
19099
19898
  }
19100
19899
  else {
19101
- this._enterFolder(node.type === 'folder' ? node : node.parent);
19900
+ this._requestEnterFolder(node.type === 'folder' ? node : node.parent);
19102
19901
  this._previewAsset(node);
19103
19902
  if (node.type !== 'folder') {
19104
19903
  this.content.querySelectorAll('.lexassetitem').forEach((i) => i.classList.remove('selected'));
@@ -19470,12 +20269,41 @@ class AssetView {
19470
20269
  this.toolsPanel.refresh();
19471
20270
  this._refreshContent();
19472
20271
  }
19473
- async _enterFolder(folderItem, storeCurrent = true) {
20272
+ _requestEnterFolder(folderItem, storeCurrent = true) {
20273
+ if (!folderItem) {
20274
+ return;
20275
+ }
20276
+ const onBeforeEnterFolder = this._callbacks['beforeEnterFolder'];
20277
+ const onEnterFolder = this._callbacks['enterFolder'];
20278
+ const resolve = (...args) => {
20279
+ const child = this.currentData[0];
20280
+ const sameFolder = child?.parent?.metadata?.uid === folderItem.metadata?.uid;
20281
+ const mustRefresh = args[0] || !sameFolder;
20282
+ this._enterFolder(folderItem, storeCurrent, mustRefresh);
20283
+ const event = {
20284
+ type: 'enter-folder',
20285
+ to: folderItem,
20286
+ userInitiated: true
20287
+ };
20288
+ if (onEnterFolder)
20289
+ onEnterFolder(event, ...args);
20290
+ };
20291
+ if (onBeforeEnterFolder) {
20292
+ const event = {
20293
+ type: 'enter-folder',
20294
+ to: folderItem,
20295
+ userInitiated: true
20296
+ };
20297
+ onBeforeEnterFolder(event, resolve);
20298
+ }
20299
+ else {
20300
+ resolve();
20301
+ }
20302
+ }
20303
+ _enterFolder(folderItem, storeCurrent, mustRefresh) {
19474
20304
  if (!folderItem) {
19475
20305
  return;
19476
20306
  }
19477
- const child = this.currentData[0];
19478
- const sameFolder = child?.parent?.metadata?.uid === folderItem.metadata?.uid;
19479
20307
  if (storeCurrent) {
19480
20308
  this.prevData.push(this.currentFolder ?? {
19481
20309
  id: '/',
@@ -19484,17 +20312,6 @@ class AssetView {
19484
20312
  metadata: {}
19485
20313
  });
19486
20314
  }
19487
- let mustRefresh = !sameFolder;
19488
- const onEnterFolder = this._callbacks['enterFolder'];
19489
- if (onEnterFolder !== undefined) {
19490
- const event = {
19491
- type: 'enter_folder',
19492
- to: folderItem,
19493
- userInitiated: true
19494
- };
19495
- const r = await onEnterFolder(event);
19496
- mustRefresh = mustRefresh || r;
19497
- }
19498
20315
  // Update this after the event since the user might have added or modified the data
19499
20316
  this.currentFolder = folderItem;
19500
20317
  this.currentData = this.currentFolder?.children ?? [];