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.
package/build/lexgui.js CHANGED
@@ -16,7 +16,7 @@
16
16
  exports.LX = g$1.LX;
17
17
  if (!exports.LX) {
18
18
  exports.LX = {
19
- version: '8.3.1',
19
+ version: '8.4.0',
20
20
  ready: false,
21
21
  extensions: [], // Store extensions used
22
22
  extraCommandbarEntries: [], // User specific entries for command bar
@@ -436,6 +436,8 @@
436
436
  ComponentType[ComponentType["LABEL"] = 39] = "LABEL";
437
437
  ComponentType[ComponentType["BLANK"] = 40] = "BLANK";
438
438
  ComponentType[ComponentType["RATE"] = 41] = "RATE";
439
+ ComponentType[ComponentType["EMPTY"] = 42] = "EMPTY";
440
+ ComponentType[ComponentType["DESCRIPTION"] = 43] = "DESCRIPTION";
439
441
  })(exports.ComponentType || (exports.ComponentType = {}));
440
442
  exports.LX.ComponentType = exports.ComponentType;
441
443
  /**
@@ -737,6 +739,9 @@
737
739
  const realNameWidth = this.root.domName?.style.width ?? '0px';
738
740
  wValue.style.width = `calc( 100% - ${realNameWidth})`;
739
741
  };
742
+ this.onSetDisabled = (disabled) => {
743
+ wValue.disabled = disabled;
744
+ };
740
745
  // In case of swap, set if a change has to be performed
741
746
  this.setState = function (v, skipCallback) {
742
747
  const swapInput = wValue.querySelector('input');
@@ -1474,6 +1479,12 @@
1474
1479
  const realNameWidth = this.root.domName?.style.width ?? '0px';
1475
1480
  container.style.width = options.inputWidth ?? `calc( 100% - ${realNameWidth})`;
1476
1481
  };
1482
+ this.onSetDisabled = (disabled) => {
1483
+ vecinput.disabled = disabled;
1484
+ const slider = this.root.querySelector('input[type="range"]');
1485
+ if (slider)
1486
+ slider.disabled = disabled;
1487
+ };
1477
1488
  this.setLimits = (newMin, newMax, newStep) => { };
1478
1489
  var container = document.createElement('div');
1479
1490
  container.className = 'lexnumber';
@@ -1504,7 +1515,7 @@
1504
1515
  if (!options.skipSlider && options.min !== undefined && options.max !== undefined) {
1505
1516
  let sliderBox = exports.LX.makeContainer(['100%', 'auto'], 'z-1 input-box', '', box);
1506
1517
  let slider = document.createElement('input');
1507
- slider.className = 'lexinputslider';
1518
+ slider.className = 'lexinputslider disabled:pointer-events-none disabled:opacity-50';
1508
1519
  slider.min = options.min;
1509
1520
  slider.max = options.max;
1510
1521
  slider.step = options.step ?? 1;
@@ -1641,6 +1652,11 @@
1641
1652
  const realNameWidth = this.root.domName?.style.width ?? '0px';
1642
1653
  container.style.width = options.inputWidth ?? `calc( 100% - ${realNameWidth})`;
1643
1654
  };
1655
+ this.onSetDisabled = (disabled) => {
1656
+ const input = this.root.querySelector('input');
1657
+ if (input)
1658
+ input.disabled = disabled;
1659
+ };
1644
1660
  this.valid = (v, matchField) => {
1645
1661
  v = v ?? this.value();
1646
1662
  if (!options.pattern)
@@ -1800,6 +1816,9 @@
1800
1816
  const realNameWidth = this.root.domName?.style.width ?? '0px';
1801
1817
  container.style.width = options.inputWidth ?? `calc( 100% - ${realNameWidth})`;
1802
1818
  };
1819
+ this.onSetDisabled = (disabled) => {
1820
+ selectedOption?.setDisabled(disabled);
1821
+ };
1803
1822
  let container = document.createElement('div');
1804
1823
  container.className = 'lexselect';
1805
1824
  this.root.appendChild(container);
@@ -2074,6 +2093,13 @@
2074
2093
  this._trigger(new IEvent(name, values, event), callback);
2075
2094
  }
2076
2095
  };
2096
+ this.onSetDisabled = (disabled) => {
2097
+ if (this.root.dataset['opened'] == 'true' && disabled) {
2098
+ this.root.dataset['opened'] = false;
2099
+ this.root.querySelector('.lexarrayitems').toggleAttribute('hidden', true);
2100
+ }
2101
+ toggleButton.setDisabled(disabled);
2102
+ };
2077
2103
  // Add open array button
2078
2104
  let container = document.createElement('div');
2079
2105
  container.className = 'lexarray shrink-1 grow-1 ml-4';
@@ -2165,9 +2191,10 @@
2165
2191
  const container = exports.LX.makeContainer(['100%', 'auto'], 'lexcard max-w-sm flex flex-col gap-4 bg-card border-color rounded-xl py-6', '', this.root);
2166
2192
  if (options.header) {
2167
2193
  const hasAction = options.header.action !== undefined;
2194
+ const actionButtonOptions = options.header.action.options ?? {};
2168
2195
  let header = exports.LX.makeContainer(['100%', 'auto'], `flex ${hasAction ? 'flex-row gap-4' : 'flex-col gap-1'} px-6`, '', container);
2169
2196
  if (hasAction) {
2170
- const actionBtn = new Button(null, options.header.action.name, options.header.action.callback, { buttonClass: 'secondary' });
2197
+ const actionBtn = new Button(null, options.header.action.name, options.header.action.callback, { buttonClass: 'secondary', ...actionButtonOptions });
2171
2198
  header.appendChild(actionBtn.root);
2172
2199
  const titleDescBox = exports.LX.makeContainer(['75%', 'auto'], `flex flex-col gap-1`, '');
2173
2200
  header.prepend(titleDescBox);
@@ -2234,6 +2261,9 @@
2234
2261
  const realNameWidth = this.root.domName?.style.width ?? '0px';
2235
2262
  container.style.width = options.inputWidth ?? `calc( 100% - ${realNameWidth})`;
2236
2263
  };
2264
+ this.onSetDisabled = (disabled) => {
2265
+ checkbox.disabled = disabled;
2266
+ };
2237
2267
  let container = document.createElement('div');
2238
2268
  container.className = 'flex items-center gap-2 my-0 mx-auto [&_span]:truncate [&_span]:flex-auto-fill';
2239
2269
  this.root.appendChild(container);
@@ -2862,7 +2892,12 @@
2862
2892
  const realNameWidth = this.root.domName?.style.width ?? '0px';
2863
2893
  container.style.width = `calc( 100% - ${realNameWidth})`;
2864
2894
  };
2865
- var container = document.createElement('span');
2895
+ this.onSetDisabled = (disabled) => {
2896
+ textComponent.setDisabled(disabled);
2897
+ sampleContainer.classList.toggle('pointer-events-none', disabled);
2898
+ sampleContainer.classList.toggle('opacity-50', disabled);
2899
+ };
2900
+ let container = document.createElement('span');
2866
2901
  container.className = 'lexcolor';
2867
2902
  this.root.appendChild(container);
2868
2903
  this.picker = new ColorPicker(value, {
@@ -3026,6 +3061,11 @@
3026
3061
  this._trigger(new IEvent(name, newValue, event), callback);
3027
3062
  }
3028
3063
  };
3064
+ this.onSetDisabled = (disabled) => {
3065
+ substrButton.setDisabled(disabled);
3066
+ addButton.setDisabled(disabled);
3067
+ input.disabled = disabled;
3068
+ };
3029
3069
  this.count = value;
3030
3070
  const min = options.min ?? 0;
3031
3071
  const max = options.max ?? 100;
@@ -3771,6 +3811,12 @@
3771
3811
  const realNameWidth = this.root.domName?.style.width ?? '0px';
3772
3812
  container.style.width = `calc( 100% - ${realNameWidth})`;
3773
3813
  };
3814
+ this.onSetDisabled = (disabled) => {
3815
+ const buttons = this.root.querySelectorAll('button');
3816
+ buttons.forEach((b) => {
3817
+ b.disabled = disabled;
3818
+ });
3819
+ };
3774
3820
  const container = exports.LX.makeContainer(['auto', 'auto'], 'lexdate flex flex-row');
3775
3821
  this.root.appendChild(container);
3776
3822
  if (!dateAsRange) {
@@ -4163,6 +4209,52 @@
4163
4209
  exports.LX.CanvasDial = CanvasDial;
4164
4210
  exports.LX.Dial = Dial;
4165
4211
 
4212
+ // Empty.ts @jxarco
4213
+ /**
4214
+ * @class Empty
4215
+ * @description Empty Component
4216
+ */
4217
+ class Empty extends BaseComponent {
4218
+ constructor(name, options = {}) {
4219
+ options.hideName = true;
4220
+ super(exports.ComponentType.EMPTY, name, null, options);
4221
+ this.root.classList.add('place-content-center');
4222
+ const container = exports.LX.makeContainer(['100%', 'auto'], 'lexcard max-w-sm flex flex-col gap-4 bg-card border-color rounded-xl py-6', '', this.root);
4223
+ if (options.header) {
4224
+ let header = exports.LX.makeContainer(['100%', 'auto'], `flex flex-col gap-4 px-6 items-center`, '', container);
4225
+ if (options.header.icon) {
4226
+ const icon = exports.LX.makeIcon(options.header.icon, { iconClass: 'bg-secondary p-2 rounded-lg!', svgClass: 'lg' });
4227
+ header.appendChild(icon);
4228
+ }
4229
+ else if (options.header.avatar) {
4230
+ const avatar = new exports.LX.Avatar(options.header.avatar);
4231
+ header.appendChild(avatar.root);
4232
+ }
4233
+ if (options.header.title) {
4234
+ exports.LX.makeElement('div', 'text-center text-foreground leading-none font-medium', options.header.title, header);
4235
+ }
4236
+ if (options.header.description) {
4237
+ exports.LX.makeElement('div', 'text-sm text-center text-balance text-muted-foreground', options.header.description, header);
4238
+ }
4239
+ }
4240
+ if (options.actions) {
4241
+ const content = exports.LX.makeContainer(['100%', 'auto'], 'flex flex-row gap-1 px-6 justify-center', '', container);
4242
+ for (let a of options.actions) {
4243
+ const action = new exports.LX.Button(null, a.name, a.callback, { buttonClass: "sm outline", ...a.options });
4244
+ content.appendChild(action.root);
4245
+ }
4246
+ }
4247
+ if (options.footer) {
4248
+ const footer = exports.LX.makeContainer(['100%', 'auto'], 'flex flex-col gap-1 px-6', '', container);
4249
+ const elements = [].concat(options.footer);
4250
+ for (let e of elements) {
4251
+ footer.appendChild(e.root ? e.root : e);
4252
+ }
4253
+ }
4254
+ }
4255
+ }
4256
+ exports.LX.Empty = Empty;
4257
+
4166
4258
  // FileInput.ts @jxarco
4167
4259
  /**
4168
4260
  * @class FileInput
@@ -4178,6 +4270,9 @@
4178
4270
  const realNameWidth = this.root.domName?.style.width ?? '0px';
4179
4271
  input.style.width = `calc( 100% - ${realNameWidth})`;
4180
4272
  };
4273
+ this.onSetDisabled = (disabled) => {
4274
+ input.disabled = disabled;
4275
+ };
4181
4276
  // Create hidden input
4182
4277
  let input = document.createElement('input');
4183
4278
  input.className = 'lexfileinput';
@@ -4385,6 +4480,9 @@
4385
4480
  const realNameWidth = this.root.domName?.style.width ?? '0px';
4386
4481
  container.style.width = `calc( 100% - ${realNameWidth})`;
4387
4482
  };
4483
+ this.onSetDisabled = (disabled) => {
4484
+ this.setLayers(value);
4485
+ };
4388
4486
  const container = exports.LX.makeElement('div', 'lexlayers grid', '', this.root);
4389
4487
  const maxBits = options.maxBits ?? 16;
4390
4488
  this.setLayers = (val) => {
@@ -4461,6 +4559,9 @@
4461
4559
  const realNameWidth = this.root.domName?.style.width ?? '0px';
4462
4560
  listContainer.style.width = `calc( 100% - ${realNameWidth})`;
4463
4561
  };
4562
+ this.onSetDisabled = (disabled) => {
4563
+ this._updateValues(values);
4564
+ };
4464
4565
  this._updateValues = (newValues) => {
4465
4566
  values = newValues;
4466
4567
  listContainer.innerHTML = '';
@@ -4928,16 +5029,19 @@
4928
5029
  const realNameWidth = this.root.domName?.style.width ?? '0px';
4929
5030
  container.style.width = `calc( 100% - ${realNameWidth})`;
4930
5031
  };
5032
+ this.onSetDisabled = (disabled) => {
5033
+ openerButton.setDisabled(disabled);
5034
+ };
4931
5035
  var container = document.createElement('div');
4932
5036
  container.className = 'lexmap2d';
4933
5037
  this.root.appendChild(container);
4934
5038
  this.map2d = new CanvasMap2D(points, callback, options);
4935
- const calendarIcon = exports.LX.makeIcon(options.mapIcon ?? 'SquareMousePointer');
4936
- const calendarButton = new Button(null, 'Open Map', () => {
4937
- this._popover = new Popover(calendarButton.root, [this.map2d]);
5039
+ const icon = exports.LX.makeIcon(options.mapIcon ?? 'SquareMousePointer');
5040
+ const openerButton = new Button(null, 'Open Map', () => {
5041
+ this._popover = new Popover(openerButton.root, [this.map2d]);
4938
5042
  }, { buttonClass: `outline justify-between`, disabled: this.disabled });
4939
- calendarButton.root.querySelector('button').appendChild(calendarIcon);
4940
- container.appendChild(calendarButton.root);
5043
+ openerButton.root.querySelector('button').appendChild(icon);
5044
+ container.appendChild(openerButton.root);
4941
5045
  exports.LX.doAsync(this.onResize.bind(this));
4942
5046
  }
4943
5047
  }
@@ -5644,6 +5748,9 @@
5644
5748
  const realNameWidth = this.root.domName?.style.width ?? '0px';
5645
5749
  container.style.width = `calc( 100% - ${realNameWidth})`;
5646
5750
  };
5751
+ this.onSetDisabled = (disabled) => {
5752
+ _refreshInput(value);
5753
+ };
5647
5754
  const container = document.createElement('div');
5648
5755
  container.className = 'lexotp flex flex-row items-center';
5649
5756
  this.root.appendChild(container);
@@ -5657,7 +5764,7 @@
5657
5764
  for (let j = 0; j < g.length; ++j) {
5658
5765
  let number = valueString[itemsCount++];
5659
5766
  number = number == 'x' ? '' : number;
5660
- const slotDom = exports.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);
5767
+ const slotDom = exports.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);
5661
5768
  slotDom.tabIndex = '1';
5662
5769
  if (this.disabled) {
5663
5770
  slotDom.classList.add('disabled');
@@ -6061,6 +6168,9 @@
6061
6168
  slider.style.setProperty('--range-fix-max-offset', `${diffMaxOffset}rem`);
6062
6169
  }
6063
6170
  };
6171
+ this.onSetDisabled = (disabled) => {
6172
+ slider.disabled = disabled;
6173
+ };
6064
6174
  const container = document.createElement('div');
6065
6175
  container.className = 'lexrange relative py-3';
6066
6176
  this.root.appendChild(container);
@@ -6169,6 +6279,9 @@
6169
6279
  const realNameWidth = this.root.domName?.style.width ?? '0px';
6170
6280
  container.style.width = `calc( 100% - ${realNameWidth})`;
6171
6281
  };
6282
+ this.onSetDisabled = (disabled) => {
6283
+ container.dataset['disabled'] = disabled.toString();
6284
+ };
6172
6285
  const container = document.createElement('div');
6173
6286
  container.className = 'lexrate relative data-[disabled=true]:pointer-events-none';
6174
6287
  container.dataset['disabled'] = this.disabled.toString();
@@ -7312,6 +7425,9 @@
7312
7425
  const realNameWidth = this.root.domName?.style.width ?? '0px';
7313
7426
  tagsContainer.style.width = `calc( 100% - ${realNameWidth})`;
7314
7427
  };
7428
+ this.onSetDisabled = (disabled) => {
7429
+ this.generateTags(arrayValue);
7430
+ };
7315
7431
  // Show tags
7316
7432
  const tagsContainer = document.createElement('div');
7317
7433
  tagsContainer.className = 'inline-flex flex-wrap gap-1 bg-card/50 rounded-lg pad-xs [&_input]:w-2/3';
@@ -7380,6 +7496,11 @@
7380
7496
  const realNameWidth = this.root.domName?.style.width ?? '0px';
7381
7497
  container.style.width = options.inputWidth ?? `calc( 100% - ${realNameWidth})`;
7382
7498
  };
7499
+ this.onSetDisabled = (disabled) => {
7500
+ const textarea = this.root.querySelector('textarea');
7501
+ if (textarea)
7502
+ textarea.disabled = disabled;
7503
+ };
7383
7504
  let container = document.createElement('div');
7384
7505
  container.className = 'lextextarea';
7385
7506
  container.style.display = 'flex';
@@ -7497,6 +7618,9 @@
7497
7618
  const realNameWidth = this.root.domName?.style.width ?? '0px';
7498
7619
  container.style.width = options.inputWidth ?? `calc( 100% - ${realNameWidth})`;
7499
7620
  };
7621
+ this.onSetDisabled = (disabled) => {
7622
+ toggle.disabled = disabled;
7623
+ };
7500
7624
  var container = document.createElement('div');
7501
7625
  container.className = 'flex flex-row gap-2 items-center';
7502
7626
  this.root.appendChild(container);
@@ -7566,6 +7690,12 @@
7566
7690
  const realNameWidth = this.root.domName?.style.width ?? '0px';
7567
7691
  container.style.width = `calc( 100% - ${realNameWidth})`;
7568
7692
  };
7693
+ this.onSetDisabled = (disabled) => {
7694
+ const inputs = this.root.querySelectorAll('input');
7695
+ inputs.forEach((i) => {
7696
+ i.disabled = disabled;
7697
+ });
7698
+ };
7569
7699
  this.setLimits = (newMin, newMax, newStep) => { };
7570
7700
  const vectorInputs = [];
7571
7701
  var container = document.createElement('div');
@@ -8310,6 +8440,19 @@
8310
8440
  component.type = exports.ComponentType.LABEL;
8311
8441
  return component;
8312
8442
  }
8443
+ /**
8444
+ * @method addDescription
8445
+ * @param {String} value Information string
8446
+ * @param {Object} options Text options
8447
+ */
8448
+ addDescription(value, options = {}) {
8449
+ options.disabled = true;
8450
+ options.fitHeight = true;
8451
+ options.inputClass = exports.LX.mergeClass('bg-none', options.inputClass);
8452
+ const component = this.addTextArea(null, value, null, options);
8453
+ component.type = exports.ComponentType.DESCRIPTION;
8454
+ return component;
8455
+ }
8313
8456
  /**
8314
8457
  * @method addButton
8315
8458
  * @param {String} name Component name
@@ -8348,18 +8491,22 @@
8348
8491
  }
8349
8492
  /**
8350
8493
  * @method addCard
8351
- * @param {String} name Card Name
8352
- * @param {Object} options:
8353
- * text: Card text
8354
- * link: Card link
8355
- * title: Card dom title
8356
- * src: url of the image
8357
- * callback (Function): function to call on click
8494
+ * @param {String} name
8495
+ * @param {Object} options
8358
8496
  */
8359
8497
  addCard(name, options = {}) {
8360
8498
  const component = new Card(name, options);
8361
8499
  return this._attachComponent(component);
8362
8500
  }
8501
+ /**
8502
+ * @method addEmpty
8503
+ * @param {String} name
8504
+ * @param {Object} options
8505
+ */
8506
+ addEmpty(name, options = {}) {
8507
+ const component = new Empty(name, options);
8508
+ return this._attachComponent(component);
8509
+ }
8363
8510
  /**
8364
8511
  * @method addForm
8365
8512
  * @param {String} name Component name
@@ -11133,47 +11280,67 @@
11133
11280
  * offsetY: Tooltip margin vertical offset
11134
11281
  * active: Tooltip active by default [true]
11135
11282
  * callback: Callback function to execute when the tooltip is shown
11283
+ * delay: Interest delay in ms until showing the tooltip [100]
11136
11284
  */
11137
11285
  function asTooltip(trigger, content, options = {}) {
11138
11286
  console.assert(trigger, 'You need a trigger to generate a tooltip!');
11139
11287
  trigger.dataset['disableTooltip'] = !(options.active ?? true);
11140
11288
  let tooltipDom = null;
11289
+ let delayTimer = null;
11290
+ let rafId = null;
11141
11291
  const _offset = options.offset;
11142
11292
  const _offsetX = options.offsetX ?? (_offset ?? 0);
11143
11293
  const _offsetY = options.offsetY ?? (_offset ?? 6);
11144
- trigger.addEventListener('mouseenter', function (e) {
11145
- if (trigger.dataset['disableTooltip'] == 'true') {
11294
+ const _delay = options.delay ?? 100;
11295
+ const _cleanup = () => {
11296
+ clearTimeout(delayTimer);
11297
+ if (rafId !== null) {
11298
+ cancelAnimationFrame(rafId);
11299
+ rafId = null;
11300
+ }
11301
+ if (tooltipDom) {
11302
+ tooltipDom.remove();
11303
+ tooltipDom = null;
11304
+ }
11305
+ };
11306
+ const _watchConnection = () => {
11307
+ if (!trigger.isConnected) {
11308
+ _cleanup();
11146
11309
  return;
11147
11310
  }
11311
+ if (tooltipDom)
11312
+ rafId = requestAnimationFrame(_watchConnection);
11313
+ };
11314
+ const _showTooltip = () => {
11148
11315
  tooltipDom = exports.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);
11149
11316
  const nestedDialog = trigger.closest('dialog');
11150
11317
  const tooltipParent = nestedDialog ?? exports.LX.root;
11151
- // Remove other first
11318
+ // Remove others first
11152
11319
  exports.LX.root.querySelectorAll('.lextooltip').forEach((e) => e.remove());
11153
- // Append new tooltip
11154
11320
  tooltipParent.appendChild(tooltipDom);
11321
+ // Watch for trigger being removed from the DOM before mouseleave fires
11322
+ rafId = requestAnimationFrame(_watchConnection);
11155
11323
  exports.LX.doAsync(() => {
11324
+ if (!tooltipDom)
11325
+ return;
11156
11326
  const position = [0, 0];
11157
11327
  const offsetX = parseFloat(trigger.dataset['tooltipOffsetX'] ?? _offsetX);
11158
11328
  const offsetY = parseFloat(trigger.dataset['tooltipOffsetY'] ?? _offsetY);
11159
11329
  const rect = trigger.getBoundingClientRect();
11160
- let alignWidth = true;
11161
- switch (options.side ?? 'top') {
11330
+ const side = options.side ?? 'top';
11331
+ const alignWidth = side === 'top' || side === 'bottom';
11332
+ switch (side) {
11162
11333
  case 'left':
11163
11334
  position[0] += rect.x - tooltipDom.offsetWidth - offsetX;
11164
- alignWidth = false;
11165
11335
  break;
11166
11336
  case 'right':
11167
11337
  position[0] += rect.x + rect.width + offsetX;
11168
- alignWidth = false;
11169
11338
  break;
11170
11339
  case 'top':
11171
11340
  position[1] += rect.y - tooltipDom.offsetHeight - offsetY;
11172
- alignWidth = true;
11173
11341
  break;
11174
11342
  case 'bottom':
11175
11343
  position[1] += rect.y + rect.height + offsetY;
11176
- alignWidth = true;
11177
11344
  break;
11178
11345
  }
11179
11346
  if (alignWidth)
@@ -11184,22 +11351,22 @@
11184
11351
  position[0] = exports.LX.clamp(position[0], 0, window.innerWidth - tooltipDom.offsetWidth - 4);
11185
11352
  position[1] = exports.LX.clamp(position[1], 0, window.innerHeight - tooltipDom.offsetHeight - 4);
11186
11353
  if (nestedDialog) {
11187
- let parentRect = tooltipParent.getBoundingClientRect();
11354
+ const parentRect = tooltipParent.getBoundingClientRect();
11188
11355
  position[0] -= parentRect.x;
11189
11356
  position[1] -= parentRect.y;
11190
11357
  }
11191
11358
  tooltipDom.style.left = `${position[0]}px`;
11192
11359
  tooltipDom.style.top = `${position[1]}px`;
11193
- if (options.callback) {
11194
- options.callback(tooltipDom, trigger);
11195
- }
11360
+ options.callback?.(tooltipDom, trigger);
11196
11361
  });
11197
- });
11198
- trigger.addEventListener('mouseleave', function (e) {
11199
- if (tooltipDom) {
11200
- tooltipDom.remove();
11362
+ };
11363
+ trigger.addEventListener('mouseenter', function () {
11364
+ if (trigger.dataset['disableTooltip'] == 'true') {
11365
+ return;
11201
11366
  }
11367
+ delayTimer = setTimeout(_showTooltip, _delay);
11202
11368
  });
11369
+ trigger.addEventListener('mouseleave', _cleanup);
11203
11370
  }
11204
11371
  exports.LX.asTooltip = asTooltip;
11205
11372
  function insertChildAtIndex(parent, child, index = Infinity) {
@@ -13321,7 +13488,9 @@
13321
13488
  // using a fullscreen SVG with "rect" elements
13322
13489
  _generateMask(reference) {
13323
13490
  this.tourContainer.innerHTML = ''; // Clear previous content
13491
+ const scrollTop = document.scrollingElement?.scrollTop ?? 0;
13324
13492
  this.tourMask = exports.LX.makeContainer(['100%', '100%'], 'tour-mask absolute inset-0');
13493
+ this.tourMask.style.top = `${scrollTop}px`;
13325
13494
  this.tourContainer.appendChild(this.tourMask);
13326
13495
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
13327
13496
  svg.style.width = '100%';
@@ -13384,7 +13553,7 @@
13384
13553
  // Reference Highlight
13385
13554
  const refContainer = exports.LX.makeContainer(['0', '0'], 'tour-ref-mask absolute');
13386
13555
  refContainer.style.left = `${boundingX - hOffset - 1}px`;
13387
- refContainer.style.top = `${boundingY - vOffset - 1}px`;
13556
+ refContainer.style.top = `${boundingY - vOffset - 1 + scrollTop}px`;
13388
13557
  refContainer.style.width = `${boundingWidth + hOffset * 2 + 2}px`;
13389
13558
  refContainer.style.height = `${boundingHeight + vOffset * 2 + 2}px`;
13390
13559
  this.tourContainer.appendChild(refContainer);
@@ -14429,12 +14598,12 @@
14429
14598
  getText(separator = '\n') {
14430
14599
  return this._lines.join(separator);
14431
14600
  }
14432
- setText(text) {
14601
+ setText(text, silent = false) {
14433
14602
  this._lines = text.split(/\r?\n/);
14434
14603
  if (this._lines.length === 0) {
14435
14604
  this._lines = [''];
14436
14605
  }
14437
- if (this.onChange)
14606
+ if (!silent && this.onChange)
14438
14607
  this.onChange(this);
14439
14608
  }
14440
14609
  getCharAt(line, col) {
@@ -14598,6 +14767,7 @@
14598
14767
  _lastPushTime = 0;
14599
14768
  _groupThresholdMs;
14600
14769
  _maxSteps;
14770
+ _savedDepth = 0;
14601
14771
  constructor(groupThresholdMs = 2000, maxSteps = 200) {
14602
14772
  this._groupThresholdMs = groupThresholdMs;
14603
14773
  this._maxSteps = maxSteps;
@@ -14669,11 +14839,19 @@
14669
14839
  canRedo() {
14670
14840
  return this._redoStack.length > 0;
14671
14841
  }
14842
+ markSaved() {
14843
+ this._flush();
14844
+ this._savedDepth = this._undoStack.length;
14845
+ }
14846
+ isModified() {
14847
+ return this._undoStack.length !== this._savedDepth || this._pendingOps.length > 0;
14848
+ }
14672
14849
  clear() {
14673
14850
  this._undoStack.length = 0;
14674
14851
  this._redoStack.length = 0;
14675
14852
  this._pendingOps.length = 0;
14676
14853
  this._lastPushTime = 0;
14854
+ this._savedDepth = 0;
14677
14855
  }
14678
14856
  _flush() {
14679
14857
  if (this._pendingOps.length === 0)
@@ -14969,6 +15147,49 @@
14969
15147
  this.cursors = merged;
14970
15148
  }
14971
15149
  }
15150
+ /**
15151
+ * Strips string literals and single-line comments from a line of code,
15152
+ * leaving only the structural characters (braces, operators, keywords etc).
15153
+ */
15154
+ function stripLiteralsAndComments(line) {
15155
+ let result = '';
15156
+ let i = 0;
15157
+ while (i < line.length) {
15158
+ const ch = line[i];
15159
+ // Remove single-line comment
15160
+ if (ch === '/' && line[i + 1] === '/') {
15161
+ break;
15162
+ }
15163
+ // Block comment (same line): skip to closing */
15164
+ if (ch === '/' && line[i + 1] === '*') {
15165
+ i += 2;
15166
+ while (i < line.length && !(line[i] === '*' && line[i + 1] === '/'))
15167
+ i++;
15168
+ i += 2;
15169
+ continue;
15170
+ }
15171
+ // Remove strings (single, double, template)
15172
+ if (ch === '"' || ch === "'" || ch === '`') {
15173
+ const quote = ch;
15174
+ i++;
15175
+ while (i < line.length) {
15176
+ if (line[i] === '\\') {
15177
+ i += 2;
15178
+ continue;
15179
+ } // escaped char
15180
+ if (line[i] === quote) {
15181
+ i++;
15182
+ break;
15183
+ } // closing quote
15184
+ i++;
15185
+ }
15186
+ continue;
15187
+ }
15188
+ result += ch;
15189
+ i++;
15190
+ }
15191
+ return result;
15192
+ }
14972
15193
  /**
14973
15194
  * Manages code symbols for autocomplete, navigation, and outlining.
14974
15195
  * Incrementally updates as lines change.
@@ -14977,7 +15198,8 @@
14977
15198
  _symbols = new Map(); // name -> symbols[]
14978
15199
  _lineSymbols = []; // [lineNum] -> symbols declared on that line
14979
15200
  _scopeStack = [{ name: 'global', type: 'global', line: 0 }];
14980
- _lineScopes = []; // [lineNum] -> scope stack at that line
15201
+ _lineScopes = []; // [lineNum] -> scope stack at start of that line
15202
+ _lineScopesEnd = []; // [lineNum] -> scope stack at end of that line
14981
15203
  get currentScope() {
14982
15204
  return this._scopeStack[this._scopeStack.length - 1]?.name ?? 'global';
14983
15205
  }
@@ -14987,6 +15209,9 @@
14987
15209
  getScopeAtLine(line) {
14988
15210
  return this._lineScopes[line] ?? [{ name: 'global', type: 'global', line: 0 }];
14989
15211
  }
15212
+ getLineScopeEnd(line) {
15213
+ return this._lineScopesEnd[line] ?? [{ name: 'global', type: 'global', line: 0 }];
15214
+ }
14990
15215
  getSymbols(name) {
14991
15216
  return this._symbols.get(name) ?? [];
14992
15217
  }
@@ -15005,20 +15230,26 @@
15005
15230
  }
15006
15231
  /** Update scope stack for a line (call before parsing symbols) */
15007
15232
  updateScopeForLine(line, lineText) {
15008
- // Track braces to maintain scope stack
15009
- const openBraces = (lineText.match(/\{/g) || []).length;
15010
- const closeBraces = (lineText.match(/\}/g) || []).length;
15011
- // Save current scope for this line
15233
+ if (line === 0) {
15234
+ this._scopeStack = [{ name: 'global', type: 'global', line: 0 }];
15235
+ }
15236
+ else if (this._lineScopesEnd[line - 1]) {
15237
+ this._scopeStack = [...this._lineScopesEnd[line - 1]];
15238
+ }
15239
+ const stripped = stripLiteralsAndComments(lineText);
15240
+ const openBraces = (stripped.match(/\{/g) || []).length;
15241
+ const closeBraces = (stripped.match(/\}/g) || []).length;
15012
15242
  this._lineScopes[line] = [...this._scopeStack];
15013
15243
  // Pop scopes for closing braces
15014
15244
  for (let i = 0; i < closeBraces; i++) {
15015
15245
  if (this._scopeStack.length > 1)
15016
15246
  this._scopeStack.pop();
15017
15247
  }
15018
- // Push scopes for opening braces (will be named by symbol detection)
15248
+ // Push scopes for opening braces (symbol detection will name them later)
15019
15249
  for (let i = 0; i < openBraces; i++) {
15020
15250
  this._scopeStack.push({ name: 'anonymous', type: 'anonymous', line });
15021
15251
  }
15252
+ this._lineScopesEnd[line] = [...this._scopeStack];
15022
15253
  }
15023
15254
  /** Name the most recent anonymous scope (called when detecting class/function) */
15024
15255
  nameCurrentScope(name, type) {
@@ -15062,6 +15293,7 @@
15062
15293
  resetScopes() {
15063
15294
  this._scopeStack = [{ name: 'global', type: 'global', line: 0 }];
15064
15295
  this._lineScopes = [];
15296
+ this._lineScopesEnd = [];
15065
15297
  }
15066
15298
  clear() {
15067
15299
  this._symbols.clear();
@@ -15074,23 +15306,25 @@
15074
15306
  */
15075
15307
  function parseSymbolsFromLine(lineText, tokens, line, symbolTable) {
15076
15308
  const symbols = [];
15077
- const scope = symbolTable.currentScope;
15078
- const scopeType = symbolTable.currentScopeType;
15079
- // Build set of reserved words from tokens (keywords, statements, builtins) to skip when detecting symbols
15309
+ // Use the scope snapshot from the START of this line, not currentScope/currentScopeType
15310
+ // which will reflect state AFTER updateScopeForLine already pushed/popped braces on this line...
15311
+ const lineScopes = symbolTable.getScopeAtLine(line);
15312
+ const lineScope = lineScopes[lineScopes.length - 1];
15313
+ const scope = lineScope?.name ?? 'global';
15314
+ const scopeType = lineScope?.type ?? 'global';
15080
15315
  const reservedWords = new Set();
15081
15316
  for (const token of tokens) {
15082
15317
  if (['keyword', 'statement', 'builtin', 'preprocessor'].includes(token.type)) {
15083
15318
  reservedWords.add(token.value);
15084
15319
  }
15085
15320
  }
15086
- // Track added symbols by name and approximate position to avoid duplicates
15321
+ // Track added symbols by name and approximate position using 5 chars tolerance to avoid duplicates
15087
15322
  const addedSymbols = new Set();
15088
15323
  const addSymbol = (name, kind, col = 0) => {
15089
15324
  if (!name || !name.match(/^[a-zA-Z_$][\w$]*$/))
15090
15325
  return; // Valid identifier check
15091
15326
  if (reservedWords.has(name))
15092
15327
  return;
15093
- // Unique key using 5 chars tolerance
15094
15328
  const posKey = `${name}@${Math.floor(col / 5) * 5}`;
15095
15329
  if (addedSymbols.has(posKey))
15096
15330
  return; // Already added
@@ -15106,13 +15340,14 @@
15106
15340
  { regex: /^\s*type\s+([A-Z_]\w*)\s*=/i, kind: 'type' },
15107
15341
  { regex: /^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)/i, kind: 'function' },
15108
15342
  { regex: /^\s*(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>/i, kind: 'function' },
15109
- { 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' },
15110
- { regex: /^\s*(?:public|private|protected|static|readonly)*\s*(\w+)\s*\([^)]*\)\s*[:{]/i, kind: scopeType === 'class' ? 'method' : 'function' }
15343
+ { 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' },
15344
+ { regex: /^\s*(?:(?:public|private|protected|static|readonly|async|override)\s+)*(\w+)\s*\([^)]*\)\s*[:{]/i, kind: scopeType === 'class' ? 'method' : 'function' }
15111
15345
  ];
15112
15346
  const multiPatterns = [
15113
15347
  { regex: /(?:const|let|var)\s+(\w+)/gi, kind: 'variable' },
15114
15348
  { regex: /(\w+)\s*:\s*(?:function|[A-Z]\w*)/gi, kind: 'variable' },
15115
15349
  { regex: /this\.(\w+)\s*=/gi, kind: 'property' },
15350
+ { regex: /\b(?:private|protected|public)\s+(?:(?:static|readonly)\s+)*(\w+)\s*[=:;]/gi, kind: 'property' },
15116
15351
  { regex: /new\s+([A-Z]\w+)/gi, kind: 'constructor-call' },
15117
15352
  { regex: /(\w+)\s*\(/gi, kind: 'method-call' }
15118
15353
  ];
@@ -15185,6 +15420,56 @@
15185
15420
  exports.LX.Panel;
15186
15421
  exports.LX.Tabs;
15187
15422
  exports.LX.NodeTree;
15423
+ 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;
15424
+ const URL_REGEX = /(https?:\/\/[^\s"'<>)\]]+)/g;
15425
+ /**
15426
+ * Returns true if the string token at `idx` in the token list is a module import path.
15427
+ */
15428
+ function isImportPath(tokens, idx) {
15429
+ const isWs = (t) => /^\s+$/.test(t.value);
15430
+ const isImportWord = (t) => t.value === 'require' || t.value === 'import';
15431
+ for (let i = idx - 1; i >= 0; i--) {
15432
+ const t = tokens[i];
15433
+ if (isWs(t))
15434
+ continue;
15435
+ if (t.type === 'keyword' && t.value === 'from')
15436
+ return true;
15437
+ if (isImportWord(t))
15438
+ return true;
15439
+ if (t.type === 'symbol' && t.value === '(') {
15440
+ for (let j = i - 1; j >= 0; j--) {
15441
+ const t2 = tokens[j];
15442
+ if (isWs(t2))
15443
+ continue;
15444
+ if (isImportWord(t2))
15445
+ return true;
15446
+ break;
15447
+ }
15448
+ }
15449
+ break;
15450
+ }
15451
+ return false;
15452
+ }
15453
+ /**
15454
+ * Scans a raw token value for hex color literals and returns HTML with each
15455
+ * color wrapped in a swatch span. Non-color text is HTML-escaped.
15456
+ */
15457
+ function injectColorSpans(raw, lineIndex, colOffset) {
15458
+ HEX_COLOR_RE.lastIndex = 0;
15459
+ let result = '';
15460
+ let lastIndex = 0;
15461
+ let match;
15462
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
15463
+ while ((match = HEX_COLOR_RE.exec(raw)) !== null) {
15464
+ result += esc(raw.slice(lastIndex, match.index));
15465
+ const color = match[0];
15466
+ const col = colOffset + match.index;
15467
+ 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>`;
15468
+ lastIndex = match.index + color.length;
15469
+ }
15470
+ result += esc(raw.slice(lastIndex));
15471
+ return result;
15472
+ }
15188
15473
  // _____ _ _ _____
15189
15474
  // | __|___ ___ ___| | | __ |___ ___
15190
15475
  // |__ | _| _| . | | | __ -| .'| _|
@@ -15207,6 +15492,15 @@
15207
15492
  this.thumb = exports.LX.makeElement('div');
15208
15493
  this.thumb.addEventListener('mousedown', (e) => this._onMouseDown(e));
15209
15494
  this.root.appendChild(this.thumb);
15495
+ this.root.addEventListener('mousedown', (e) => {
15496
+ if (e.target === this.thumb)
15497
+ return;
15498
+ const clickPos = this._vertical ? e.offsetY : e.offsetX;
15499
+ const thumbSize = this._vertical ? this.thumb.offsetHeight : this.thumb.offsetWidth;
15500
+ const delta = (clickPos - thumbSize / 2) - this._thumbPos;
15501
+ this._onDrag?.(delta);
15502
+ this._onMouseDown(e); // continue as drag from new position
15503
+ });
15210
15504
  }
15211
15505
  setThumbRatio(ratio) {
15212
15506
  this._thumbRatio = exports.LX.clamp(ratio, 0, 1);
@@ -15330,10 +15624,18 @@
15330
15624
  onReady;
15331
15625
  onCreateFile;
15332
15626
  onCodeChange;
15627
+ onOpenPath;
15628
+ onHoverSymbol;
15333
15629
  _inputArea;
15334
15630
  // State:
15335
15631
  _lineStates = []; // tokenizer state at end of each line
15336
15632
  _lineElements = []; // <pre> element per line
15633
+ _bracketOpenLine = -1; // line of the { opening current scope
15634
+ _bracketCloseLine = -1; // line of the } closing current scope
15635
+ _hoverTimer = null;
15636
+ _hoverPopup = null;
15637
+ _hoverWord = '';
15638
+ _colorPopover = null; // active color picker popover
15337
15639
  _openedTabs = {};
15338
15640
  _loadedTabs = {};
15339
15641
  _storedTabs = {};
@@ -15403,6 +15705,8 @@
15403
15705
  this.onSelectTab = options.onSelectTab;
15404
15706
  this.onReady = options.onReady;
15405
15707
  this.onCodeChange = options.onCodeChange;
15708
+ this.onOpenPath = options.onOpenPath;
15709
+ this.onHoverSymbol = options.onHoverSymbol;
15406
15710
  this.language = Tokenizer.getLanguage(this.highlight) ?? Tokenizer.getLanguage('Plain Text');
15407
15711
  this.symbolTable = new SymbolTable();
15408
15712
  // File explorer
@@ -15612,19 +15916,38 @@
15612
15916
  this.codeArea.root.addEventListener('mousedown', this._onMouseDown.bind(this));
15613
15917
  this.codeArea.root.addEventListener('contextmenu', this._onMouseDown.bind(this));
15614
15918
  this.codeArea.root.addEventListener('mouseover', (e) => {
15615
- const link = e.target.closest('.code-link');
15919
+ const target = e.target;
15920
+ const link = target.closest('.code-link');
15616
15921
  if (link && e.ctrlKey)
15617
15922
  link.classList.add('hovered');
15923
+ const path = target.closest('.code-path');
15924
+ if (path && e.ctrlKey)
15925
+ path.classList.add('hovered');
15618
15926
  });
15619
15927
  this.codeArea.root.addEventListener('mouseout', (e) => {
15620
- const link = e.target.closest('.code-link');
15928
+ const target = e.target;
15929
+ const link = target.closest('.code-link');
15621
15930
  if (link)
15622
15931
  link.classList.remove('hovered');
15932
+ const path = target.closest('.code-path');
15933
+ if (path)
15934
+ path.classList.remove('hovered');
15623
15935
  });
15624
15936
  this.codeArea.root.addEventListener('mousemove', (e) => {
15625
- const link = e.target.closest('.code-link');
15937
+ const target = e.target;
15938
+ const link = target.closest('.code-link');
15626
15939
  if (link)
15627
15940
  link.classList.toggle('hovered', e.ctrlKey);
15941
+ const path = target.closest('.code-path');
15942
+ if (path)
15943
+ path.classList.toggle('hovered', e.ctrlKey);
15944
+ this._onCodeAreaMouseMove(e);
15945
+ });
15946
+ this.codeArea.root.addEventListener('mouseleave', () => {
15947
+ this._clearHoverPopup();
15948
+ });
15949
+ this.codeArea.root.addEventListener('click', (e) => {
15950
+ this._onColorSwatchClick(e);
15628
15951
  });
15629
15952
  // Bottom status panel
15630
15953
  this.statusPanel = this._createStatusPanel(options);
@@ -15716,7 +16039,7 @@
15716
16039
  setText(text, language, detectLang = false) {
15717
16040
  if (!this.currentTab)
15718
16041
  return;
15719
- this.doc.setText(text);
16042
+ this.doc.setText(this._normalizeText(text), true);
15720
16043
  this.cursorSet.set(0, 0);
15721
16044
  this.undoManager.clear();
15722
16045
  this._lineStates = [];
@@ -15907,10 +16230,14 @@
15907
16230
  const codeTab = {
15908
16231
  name,
15909
16232
  dom,
15910
- doc: new CodeDocument(this.onCodeChange),
16233
+ doc: new CodeDocument((doc) => {
16234
+ this._setTabModified(name, true);
16235
+ this.onCodeChange?.(doc);
16236
+ }),
15911
16237
  cursorSet: new CursorSet(),
15912
16238
  undoManager: new UndoManager(),
15913
16239
  language: langName,
16240
+ modified: false,
15914
16241
  title: options.title ?? name
15915
16242
  };
15916
16243
  this._openedTabs[name] = codeTab;
@@ -15934,7 +16261,7 @@
15934
16261
  // Move into the sizer..
15935
16262
  this.codeSizer.appendChild(dom);
15936
16263
  if (options.text) {
15937
- codeTab.doc.setText(options.text);
16264
+ codeTab.doc.setText(options.text, true);
15938
16265
  codeTab.cursorSet.set(0, 0);
15939
16266
  codeTab.undoManager.clear();
15940
16267
  this._renderAllLines();
@@ -15995,15 +16322,14 @@
15995
16322
  }
15996
16323
  setCustomSuggestions(suggestions) {
15997
16324
  if (!suggestions || suggestions.constructor !== Array) {
15998
- console.warn('suggestions should be a string array!');
16325
+ console.warn('suggestions should be an array!');
15999
16326
  return;
16000
16327
  }
16001
- this.customSuggestions = suggestions;
16328
+ this.customSuggestions = suggestions.map(s => typeof s === 'string' ? { label: s } : s);
16002
16329
  }
16003
16330
  loadFile(file, options = {}) {
16004
16331
  const onLoad = (text, name) => {
16005
- // Remove Carriage Return in some cases and sub tabs using spaces
16006
- text = text.replaceAll('\r', '').replaceAll(/\t|\\t/g, ' '.repeat(this.tabSize));
16332
+ text = this._normalizeText(text);
16007
16333
  const ext = exports.LX.getExtension(name);
16008
16334
  const lang = options.language ?? (Tokenizer.getLanguage(options.language)
16009
16335
  ?? (Tokenizer.getLanguageByExtension(ext) ?? Tokenizer.getLanguage('Plain Text')));
@@ -16026,7 +16352,7 @@
16026
16352
  title: options.title ?? name,
16027
16353
  language: langName
16028
16354
  });
16029
- this.doc.setText(text);
16355
+ this.doc.setText(text, true);
16030
16356
  this.setLanguage(langName, ext);
16031
16357
  this.cursorSet.set(0, 0);
16032
16358
  this.undoManager.clear();
@@ -16087,7 +16413,7 @@
16087
16413
  language: langName
16088
16414
  });
16089
16415
  if (results.length === 0) {
16090
- this.doc.setText(processedText);
16416
+ this.doc.setText(processedText, true);
16091
16417
  this.setLanguage(langName, ext);
16092
16418
  this.cursorSet.set(0, 0);
16093
16419
  this.undoManager.clear();
@@ -16129,6 +16455,30 @@
16129
16455
  }
16130
16456
  }, 20);
16131
16457
  }
16458
+ _findTabByPath(importPath) {
16459
+ // By now only uses base name
16460
+ const importBase = importPath.split('/').pop().replace(/\.\w+$/, '').toLowerCase();
16461
+ const allNames = new Set([
16462
+ ...Object.keys(this._openedTabs),
16463
+ ...Object.keys(this._loadedTabs),
16464
+ ...Object.keys(this._storedTabs),
16465
+ ]);
16466
+ for (const name of allNames) {
16467
+ const tabBase = name.split('/').pop().replace(/\.\w+$/, '').toLowerCase();
16468
+ if (tabBase === importBase)
16469
+ return name;
16470
+ }
16471
+ return null;
16472
+ }
16473
+ _setTabModified(name, modified) {
16474
+ const tab = this._openedTabs[name];
16475
+ if (!tab || tab.modified === modified)
16476
+ return;
16477
+ tab.modified = modified;
16478
+ const tabEl = this.tabs?.tabDOMs?.[name];
16479
+ if (tabEl)
16480
+ tabEl.toggleAttribute('data-modified', modified);
16481
+ }
16132
16482
  _onSelectTab(isNewTabButton, event, name) {
16133
16483
  if (this.disableEdition) {
16134
16484
  return;
@@ -16354,20 +16704,55 @@
16354
16704
  const lineText = this.doc.getLine(lineIndex);
16355
16705
  const result = Tokenizer.tokenizeLine(lineText, this.language, prevState);
16356
16706
  const langClass = this.language.name.toLowerCase().replace(/[^a-z]/g, '');
16357
- const URL_REGEX = /(https?:\/\/[^\s"'<>)\]]+)/g;
16707
+ // Pre-compute which token index gets the bracket-highlight class
16708
+ let bracketTokenIdx = -1;
16709
+ if (lineIndex === this._bracketOpenLine) {
16710
+ // Last '{' symbol token on this line
16711
+ for (let i = result.tokens.length - 1; i >= 0; i--) {
16712
+ if (result.tokens[i].type === 'symbol' && result.tokens[i].value === '{') {
16713
+ bracketTokenIdx = i;
16714
+ break;
16715
+ }
16716
+ }
16717
+ }
16718
+ else if (lineIndex === this._bracketCloseLine) {
16719
+ // First '}' symbol token on this line
16720
+ for (let i = 0; i < result.tokens.length; i++) {
16721
+ if (result.tokens[i].type === 'symbol' && result.tokens[i].value === '}') {
16722
+ bracketTokenIdx = i;
16723
+ break;
16724
+ }
16725
+ }
16726
+ }
16358
16727
  let html = '';
16359
- for (const token of result.tokens) {
16728
+ let colOffset = 0;
16729
+ for (let ti = 0; ti < result.tokens.length; ti++) {
16730
+ const token = result.tokens[ti];
16360
16731
  const cls = TOKEN_CLASS_MAP[token.type];
16361
- const escaped = token.value
16362
- .replace(/&/g, '&amp;')
16363
- .replace(/</g, '&lt;')
16364
- .replace(/>/g, '&gt;');
16365
- // Wrap URLs in comment tokens with a clickable span
16366
- const content = (token.type === 'comment')
16367
- ? escaped.replace(URL_REGEX, `<span class="code-link" data-url="$1">$1</span>`)
16368
- : escaped;
16732
+ const tokenCol = colOffset;
16733
+ colOffset += token.value.length;
16734
+ // Inject content depending on type of token: color, url, path?
16735
+ let content;
16736
+ if (token.type === 'comment') {
16737
+ const escaped = token.value
16738
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
16739
+ content = escaped.replace(URL_REGEX, `<span class="code-link" data-url="$1">$1</span>`);
16740
+ }
16741
+ else if (token.type === 'string' && isImportPath(result.tokens, ti)) {
16742
+ const inner = token.value.slice(1, -1); // strip surrounding quotes
16743
+ const q = token.value[0];
16744
+ const escapedInner = inner.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
16745
+ content = `${q}<span class="code-path" data-path="${inner}">${escapedInner}</span>${q}`;
16746
+ }
16747
+ else {
16748
+ content = injectColorSpans(token.value, lineIndex, tokenCol);
16749
+ }
16750
+ const bracketClass = ti === bracketTokenIdx ? ' code-bracket-active' : '';
16369
16751
  if (cls) {
16370
- html += `<span class="${cls} ${langClass}">${content}</span>`;
16752
+ html += `<span class="${cls} ${langClass}${bracketClass}">${content}</span>`;
16753
+ }
16754
+ else if (bracketClass) {
16755
+ html += `<span class="${bracketClass.trim()}">${content}</span>`;
16371
16756
  }
16372
16757
  else {
16373
16758
  html += content;
@@ -16448,6 +16833,13 @@
16448
16833
  break;
16449
16834
  }
16450
16835
  }
16836
+ // Propagate/cascade scope updates to subsequent lines until the start-of-line scope stabilizes
16837
+ for (let i = lineIndex + 1; i < this.doc.lineCount; i++) {
16838
+ const oldStartDepth = this.symbolTable.getScopeAtLine(i).length;
16839
+ this.symbolTable.updateScopeForLine(i, this.doc.getLine(i));
16840
+ if (this.symbolTable.getScopeAtLine(i).length === oldStartDepth)
16841
+ break;
16842
+ }
16451
16843
  }
16452
16844
  /**
16453
16845
  * Rebuild line elements after structural changes (insert/delete lines).
@@ -16489,12 +16881,9 @@
16489
16881
  return;
16490
16882
  this.cursorsLayer.innerHTML = '';
16491
16883
  for (const sel of this.cursorSet.cursors) {
16492
- const el = document.createElement('div');
16493
- el.className = 'cursor';
16494
- el.innerHTML = '&nbsp;';
16884
+ const el = exports.LX.makeElement('div', 'cursor', '&nbsp;', this.cursorsLayer);
16495
16885
  el.style.left = (sel.head.col * this.charWidth + this.xPadding) + 'px';
16496
16886
  el.style.top = (sel.head.line * this.lineHeight) + 'px';
16497
- this.cursorsLayer.appendChild(el);
16498
16887
  }
16499
16888
  this._updateActiveLine();
16500
16889
  }
@@ -16511,14 +16900,16 @@
16511
16900
  const lineText = this.doc.getLine(line);
16512
16901
  const fromCol = line === start.line ? start.col : 0;
16513
16902
  const toCol = line === end.line ? end.col : lineText.length;
16514
- if (fromCol === toCol)
16903
+ // Skip only when the selection ends exactly at col 0 of this line
16904
+ if (fromCol === toCol && line === end.line)
16515
16905
  continue;
16516
- const div = document.createElement('div');
16517
- div.className = 'lexcodeselection';
16906
+ const width = fromCol === toCol
16907
+ ? Math.ceil(this.charWidth * 0.5) // minimum width for empty lines
16908
+ : (toCol - fromCol) * this.charWidth;
16909
+ const div = exports.LX.makeElement('div', 'lexcodeselection', '', this.selectionsLayer);
16518
16910
  div.style.top = (line * this.lineHeight) + 'px';
16519
16911
  div.style.left = (fromCol * this.charWidth + this.xPadding) + 'px';
16520
- div.style.width = ((toCol - fromCol) * this.charWidth) + 'px';
16521
- this.selectionsLayer.appendChild(div);
16912
+ div.style.width = width + 'px';
16522
16913
  }
16523
16914
  }
16524
16915
  }
@@ -16623,6 +17014,8 @@
16623
17014
  e.preventDefault();
16624
17015
  if (this.onSave) {
16625
17016
  this.onSave(this.getText(), this);
17017
+ this.undoManager.markSaved();
17018
+ this._setTabModified(this.currentTab.name, false);
16626
17019
  }
16627
17020
  return;
16628
17021
  case 'z':
@@ -16645,6 +17038,17 @@
16645
17038
  e.preventDefault();
16646
17039
  this._doPaste();
16647
17040
  return;
17041
+ case 'home':
17042
+ e.preventDefault();
17043
+ this.cursorSet.set(0, 0);
17044
+ this._afterCursorMove();
17045
+ return;
17046
+ case 'end':
17047
+ e.preventDefault();
17048
+ const lastLine = this.doc.lineCount - 1;
17049
+ this.cursorSet.set(lastLine, this.doc.getLine(lastLine).length);
17050
+ this._afterCursorMove();
17051
+ return;
16648
17052
  case ' ':
16649
17053
  e.preventDefault();
16650
17054
  // Also call user callback if provided
@@ -17376,10 +17780,21 @@
17376
17780
  this._rebuildLines();
17377
17781
  this._afterCursorMove();
17378
17782
  }
17783
+ /**
17784
+ * Normalize external text before inserting into the document:
17785
+ * - Unify line endings to \n
17786
+ * - Replace tab characters with the configured number of spaces
17787
+ */
17788
+ _normalizeText(text) {
17789
+ return text
17790
+ .replace(/\r\n?/g, '\n')
17791
+ .replace(/\t/g, ' '.repeat(this.tabSize));
17792
+ }
17379
17793
  async _doPaste() {
17380
- const text = await navigator.clipboard.readText();
17381
- if (!text)
17794
+ const raw = await navigator.clipboard.readText();
17795
+ if (!raw)
17382
17796
  return;
17797
+ const text = this._normalizeText(raw);
17383
17798
  this._flushAction();
17384
17799
  this._deleteSelectionIfAny();
17385
17800
  const cursor = this.cursorSet.getPrimary();
@@ -17406,6 +17821,8 @@
17406
17821
  }
17407
17822
  this._rebuildLines();
17408
17823
  this._afterCursorMove();
17824
+ if (this.currentTab)
17825
+ this._setTabModified(this.currentTab.name, this.undoManager.isModified());
17409
17826
  }
17410
17827
  }
17411
17828
  _doRedo() {
@@ -17417,6 +17834,8 @@
17417
17834
  }
17418
17835
  this._rebuildLines();
17419
17836
  this._afterCursorMove();
17837
+ if (this.currentTab)
17838
+ this._setTabModified(this.currentTab.name, this.undoManager.isModified());
17420
17839
  }
17421
17840
  }
17422
17841
  // Mouse input events:
@@ -17427,7 +17846,7 @@
17427
17846
  return;
17428
17847
  if (this.autocomplete && this.autocomplete.contains(e.target))
17429
17848
  return;
17430
- // Ctrl+click: open link if cursor is over a code-link span
17849
+ // Ctrl+click: open link or import path
17431
17850
  if (e.ctrlKey && e.button === 0) {
17432
17851
  const target = e.target;
17433
17852
  const link = target.closest('.code-link');
@@ -17435,6 +17854,15 @@
17435
17854
  window.open(link.dataset.url, '_blank');
17436
17855
  return;
17437
17856
  }
17857
+ const pathEl = target.closest('.code-path');
17858
+ if (pathEl?.dataset.path) {
17859
+ const rawPath = pathEl.dataset.path;
17860
+ const tabName = this._findTabByPath(rawPath);
17861
+ if (tabName)
17862
+ this.loadTab(tabName);
17863
+ this.onOpenPath?.(rawPath, this);
17864
+ return;
17865
+ }
17438
17866
  }
17439
17867
  e.preventDefault(); // Prevent browser from stealing focus from _inputArea
17440
17868
  this._wasPaired = false;
@@ -17482,18 +17910,55 @@
17482
17910
  }
17483
17911
  this._afterCursorMove();
17484
17912
  this._inputArea.focus();
17485
- // Track mouse for drag selection
17486
- const onMouseMove = (me) => {
17487
- const mx = me.clientX - rect.left - this.xPadding;
17488
- const my = me.clientY - rect.top;
17489
- const ml = Math.max(0, Math.min(Math.floor(my / this.lineHeight), this.doc.lineCount - 1));
17490
- const mc = Math.max(0, Math.min(Math.round(mx / this.charWidth), this.doc.getLine(ml).length));
17913
+ // Track mouse for drag selection (with auto-scroll when outside editor window/area)
17914
+ let lastMouseX = 0;
17915
+ let lastMouseY = 0;
17916
+ let rafId = null;
17917
+ const updateSelection = () => {
17918
+ const currentRect = this.codeContainer.getBoundingClientRect();
17919
+ const mx = lastMouseX - currentRect.left - this.xPadding;
17920
+ const my = lastMouseY - currentRect.top;
17921
+ const ml = exports.LX.clamp(Math.floor(my / this.lineHeight), 0, this.doc.lineCount - 1);
17922
+ const mc = exports.LX.clamp(Math.round(mx / this.charWidth), 0, this.doc.getLine(ml).length);
17491
17923
  const sel = this.cursorSet.getPrimary();
17492
17924
  sel.head = { line: ml, col: mc };
17493
17925
  this._renderCursors();
17494
17926
  this._renderSelections();
17495
17927
  };
17928
+ const autoScroll = () => {
17929
+ const scrollerRect = this.codeScroller.getBoundingClientRect();
17930
+ const overshootY = lastMouseY < scrollerRect.top ? lastMouseY - scrollerRect.top
17931
+ : lastMouseY > scrollerRect.bottom ? lastMouseY - scrollerRect.bottom : 0;
17932
+ const overshootX = lastMouseX < scrollerRect.left ? lastMouseX - scrollerRect.left
17933
+ : lastMouseX > scrollerRect.right ? lastMouseX - scrollerRect.right : 0;
17934
+ if (overshootY === 0 && overshootX === 0) {
17935
+ rafId = null;
17936
+ return;
17937
+ }
17938
+ const speedY = Math.sign(overshootY) * Math.min(Math.abs(overshootY) * 0.3, 15);
17939
+ const speedX = Math.sign(overshootX) * Math.min(Math.abs(overshootX) * 0.3, 15);
17940
+ this.codeScroller.scrollTop += speedY;
17941
+ this.codeScroller.scrollLeft += speedX;
17942
+ this._syncScrollBars();
17943
+ updateSelection();
17944
+ rafId = requestAnimationFrame(autoScroll);
17945
+ };
17946
+ const onMouseMove = (me) => {
17947
+ lastMouseX = me.clientX;
17948
+ lastMouseY = me.clientY;
17949
+ updateSelection();
17950
+ const scrollerRect = this.codeScroller.getBoundingClientRect();
17951
+ const isOutside = me.clientY < scrollerRect.top || me.clientY > scrollerRect.bottom
17952
+ || me.clientX < scrollerRect.left || me.clientX > scrollerRect.right;
17953
+ if (isOutside && rafId === null) {
17954
+ rafId = requestAnimationFrame(autoScroll);
17955
+ }
17956
+ };
17496
17957
  const onMouseUp = () => {
17958
+ if (rafId !== null) {
17959
+ cancelAnimationFrame(rafId);
17960
+ rafId = null;
17961
+ }
17497
17962
  document.removeEventListener('mousemove', onMouseMove);
17498
17963
  document.removeEventListener('mouseup', onMouseUp);
17499
17964
  };
@@ -17553,47 +18018,51 @@
17553
18018
  }
17554
18019
  const suggestions = [];
17555
18020
  const added = new Set();
17556
- const addSuggestion = (label, kind, scope, detail, insertText) => {
17557
- if (!added.has(label)) {
17558
- suggestions.push({ label, kind, scope, detail, insertText });
17559
- added.add(label);
18021
+ const addSuggestion = (s) => {
18022
+ if (!added.has(s.label)) {
18023
+ suggestions.push(s);
18024
+ added.add(s.label);
17560
18025
  }
17561
18026
  };
18027
+ const filterSuggestion = (suggestion, word) => {
18028
+ const w = word.toLowerCase();
18029
+ if (suggestion.filterText) {
18030
+ return suggestion.filterText.split(' ').some(token => token.toLowerCase().trim().startsWith(w));
18031
+ }
18032
+ return suggestion.label.toLowerCase().startsWith(w);
18033
+ };
17562
18034
  // Get first suggestions from symbol table
17563
18035
  const allSymbols = this.symbolTable.getAllSymbols();
17564
18036
  for (const symbol of allSymbols) {
17565
- if (symbol.name.toLowerCase().startsWith(word.toLowerCase())) {
17566
- addSuggestion(symbol.name, symbol.kind, symbol.scope, `${symbol.kind} in ${symbol.scope}`);
17567
- }
18037
+ const s = { label: symbol.name, kind: symbol.kind, scope: symbol.scope, detail: `${symbol.kind} in ${symbol.scope}` };
18038
+ if (filterSuggestion(s, word))
18039
+ addSuggestion(s);
17568
18040
  }
17569
18041
  // Add language reserved keys
17570
18042
  for (const reservedWord of this.language.reservedWords) {
17571
- if (reservedWord.toLowerCase().startsWith(word.toLowerCase())) {
17572
- addSuggestion(reservedWord);
17573
- }
18043
+ const s = { label: reservedWord };
18044
+ if (filterSuggestion(s, word))
18045
+ addSuggestion(s);
17574
18046
  }
17575
18047
  // Add custom suggestions
17576
18048
  for (const suggestion of this.customSuggestions) {
17577
- const label = typeof suggestion === 'string' ? suggestion : suggestion.label;
17578
- const kind = typeof suggestion === 'object' ? suggestion.kind : undefined;
17579
- const detail = typeof suggestion === 'object' ? suggestion.detail : undefined;
17580
- const insertText = typeof suggestion === 'object' ? suggestion.insertText : suggestion;
17581
- if (label.toLowerCase().startsWith(word.toLowerCase())) {
17582
- addSuggestion(label, kind, undefined, detail, insertText);
17583
- }
18049
+ if (filterSuggestion(suggestion, word))
18050
+ addSuggestion(suggestion);
17584
18051
  }
17585
- // Close autocomplete if no suggestions
17586
18052
  if (suggestions.length === 0) {
17587
18053
  this._doHideAutocomplete();
17588
18054
  return;
17589
18055
  }
17590
- // Sort suggestions: exact matches first, then alphabetically
18056
+ // Sort suggestions: exact matches first, then by sortText (or label if absent)
18057
+ const w = word.toLowerCase();
17591
18058
  suggestions.sort((a, b) => {
17592
- const aExact = a.label.toLowerCase() === word.toLowerCase() ? 0 : 1;
17593
- const bExact = b.label.toLowerCase() === word.toLowerCase() ? 0 : 1;
18059
+ const aKey = (a.sortText ?? a.label).toLowerCase();
18060
+ const bKey = (b.sortText ?? b.label).toLowerCase();
18061
+ const aExact = aKey === w ? 0 : 1;
18062
+ const bExact = bKey === w ? 0 : 1;
17594
18063
  if (aExact !== bExact)
17595
18064
  return aExact - bExact;
17596
- return a.label.localeCompare(b.label);
18065
+ return aKey.localeCompare(bKey);
17597
18066
  });
17598
18067
  this._selectedAutocompleteIndex = 0;
17599
18068
  // Render suggestions
@@ -17602,9 +18071,9 @@
17602
18071
  item.insertText = suggestion.insertText ?? suggestion.label;
17603
18072
  if (index === this._selectedAutocompleteIndex)
17604
18073
  item.classList.add('selected');
17605
- const currSuggestion = suggestion.label;
17606
- let iconName = 'CaseLower';
17607
- let iconClass = 'foo';
18074
+ const currSuggestionLabel = suggestion.label;
18075
+ let iconName = suggestion.icon ?? 'CaseLower';
18076
+ let iconClass = suggestion.iconClass ?? 'text-gray-500';
17608
18077
  switch (suggestion.kind) {
17609
18078
  case 'class':
17610
18079
  iconName = 'CircleNodes';
@@ -17651,26 +18120,22 @@
17651
18120
  iconClass = 'text-green-500';
17652
18121
  break;
17653
18122
  case 'method-call':
17654
- iconName = 'PlayCircle';
18123
+ iconName = 'Parentheses';
17655
18124
  iconClass = 'text-gray-400';
17656
18125
  break;
17657
- default:
17658
- iconName = 'CaseLower';
17659
- iconClass = 'text-gray-500';
17660
- break;
17661
18126
  }
17662
18127
  item.appendChild(exports.LX.makeIcon(iconName, { iconClass: 'ml-1 mr-2', svgClass: 'sm ' + iconClass }));
17663
18128
  // Highlight the written part
17664
- const hIndex = currSuggestion.toLowerCase().indexOf(word.toLowerCase());
18129
+ const hIndex = currSuggestionLabel.toLowerCase().indexOf(word.toLowerCase());
17665
18130
  var preWord = document.createElement('span');
17666
- preWord.textContent = currSuggestion.substring(0, hIndex);
18131
+ preWord.textContent = currSuggestionLabel.substring(0, hIndex);
17667
18132
  item.appendChild(preWord);
17668
18133
  var actualWord = document.createElement('span');
17669
- actualWord.textContent = currSuggestion.substring(hIndex, hIndex + word.length);
18134
+ actualWord.textContent = currSuggestionLabel.substring(hIndex, hIndex + word.length);
17670
18135
  actualWord.classList.add('word-highlight');
17671
18136
  item.appendChild(actualWord);
17672
18137
  var postWord = document.createElement('span');
17673
- postWord.textContent = currSuggestion.substring(hIndex + word.length);
18138
+ postWord.textContent = currSuggestionLabel.substring(hIndex + word.length);
17674
18139
  item.appendChild(postWord);
17675
18140
  if (suggestion.kind) {
17676
18141
  const kind = document.createElement('span');
@@ -17755,6 +18220,340 @@
17755
18220
  this._resetBlinker();
17756
18221
  this.resize();
17757
18222
  this._scrollCursorIntoView();
18223
+ this._updateBracketHighlight();
18224
+ }
18225
+ /**
18226
+ * Returns the scope stack at the exact cursor position (line + column).
18227
+ * Basically starts from getScopeAtLine and then counts real braces up to the cursor column.
18228
+ */
18229
+ _getScopeAtCursor() {
18230
+ const cursor = this.cursorSet.getPrimary().head;
18231
+ const line = cursor.line;
18232
+ const col = cursor.col;
18233
+ const lineText = this.doc.getLine(line);
18234
+ const scopeStack = [...this.symbolTable.getScopeAtLine(line)];
18235
+ let i = 0;
18236
+ let inString = false;
18237
+ let stringCh = '';
18238
+ while (i < col && i < lineText.length) {
18239
+ const ch = lineText[i];
18240
+ if (inString) {
18241
+ if (ch === '\\') {
18242
+ i += 2;
18243
+ continue;
18244
+ }
18245
+ if (ch === stringCh)
18246
+ inString = false;
18247
+ i++;
18248
+ continue;
18249
+ }
18250
+ if (ch === '/' && lineText[i + 1] === '/')
18251
+ break;
18252
+ if (ch === '/' && lineText[i + 1] === '*') {
18253
+ i += 2;
18254
+ while (i < col && !(lineText[i] === '*' && lineText[i + 1] === '/'))
18255
+ i++;
18256
+ i += 2;
18257
+ continue;
18258
+ }
18259
+ if (ch === '"' || ch === "'" || ch === '`') {
18260
+ inString = true;
18261
+ stringCh = ch;
18262
+ i++;
18263
+ continue;
18264
+ }
18265
+ if (ch === '{') {
18266
+ scopeStack.push({ name: 'anonymous', type: 'anonymous', line });
18267
+ }
18268
+ else if (ch === '}' && scopeStack.length > 1) {
18269
+ scopeStack.pop();
18270
+ }
18271
+ i++;
18272
+ }
18273
+ return scopeStack;
18274
+ }
18275
+ _updateBracketHighlight() {
18276
+ const scopes = this._getScopeAtCursor();
18277
+ // Find innermost non-global scope
18278
+ let innermost = null;
18279
+ for (let i = scopes.length - 1; i >= 0; i--) {
18280
+ if (scopes[i].type !== 'global') {
18281
+ innermost = scopes[i];
18282
+ break;
18283
+ }
18284
+ }
18285
+ const prevOpen = this._bracketOpenLine;
18286
+ const prevClose = this._bracketCloseLine;
18287
+ if (!innermost) {
18288
+ this._bracketOpenLine = -1;
18289
+ this._bracketCloseLine = -1;
18290
+ }
18291
+ else {
18292
+ const openLine = innermost.line;
18293
+ const targetDepth = scopes.length; // depth including the innermost scope
18294
+ // Closing line: last line where scope depth >= targetDepth
18295
+ let closeLine = openLine;
18296
+ for (let i = openLine + 1; i < this.doc.lineCount; i++) {
18297
+ if (this.symbolTable.getScopeAtLine(i).length >= targetDepth)
18298
+ closeLine = i;
18299
+ else
18300
+ break;
18301
+ }
18302
+ this._bracketOpenLine = openLine;
18303
+ this._bracketCloseLine = closeLine;
18304
+ }
18305
+ // Re-render only the lines that changed
18306
+ const linesToUpdate = new Set();
18307
+ if (prevOpen !== this._bracketOpenLine) {
18308
+ linesToUpdate.add(prevOpen);
18309
+ linesToUpdate.add(this._bracketOpenLine);
18310
+ }
18311
+ if (prevClose !== this._bracketCloseLine) {
18312
+ linesToUpdate.add(prevClose);
18313
+ linesToUpdate.add(this._bracketCloseLine);
18314
+ }
18315
+ for (const line of linesToUpdate) {
18316
+ if (line >= 0 && line < this.doc.lineCount)
18317
+ this._updateLine(line);
18318
+ }
18319
+ }
18320
+ // Color picker:
18321
+ _onColorSwatchClick(e) {
18322
+ const span = e.target.closest('.code-color');
18323
+ if (!span)
18324
+ return;
18325
+ e.stopPropagation();
18326
+ e.preventDefault();
18327
+ const colorValue = span.dataset.color;
18328
+ const lineIndex = parseInt(span.dataset.line);
18329
+ const colStart = parseInt(span.dataset.col);
18330
+ let currentLen = colorValue.length;
18331
+ if (this._colorPopover) {
18332
+ this._colorPopover.destroy();
18333
+ this._colorPopover = null;
18334
+ }
18335
+ const picker = new exports.LX.ColorPicker(colorValue, {
18336
+ colorModel: 'Hex',
18337
+ onChange: (color) => {
18338
+ // Generate a hex string matching the original length (# + 3/4/6/8 hex chars)
18339
+ const raw = color.hex.replace(/^#/, '');
18340
+ const digits = currentLen - 1;
18341
+ const newHex = '#' + (digits <= 4
18342
+ ? raw.slice(0, digits).padEnd(digits, '0')
18343
+ : raw.slice(0, Math.min(digits, 8)).padEnd(digits, '0'));
18344
+ const lineText = this.doc.getLine(lineIndex);
18345
+ if (lineText.slice(colStart, colStart + currentLen) !== span.dataset.color)
18346
+ return;
18347
+ const delOp = this.doc.delete(lineIndex, colStart, currentLen);
18348
+ this.undoManager.record(delOp, this.cursorSet.getCursorPositions());
18349
+ const insOp = this.doc.insert(lineIndex, colStart, newHex);
18350
+ this.undoManager.record(insOp, this.cursorSet.getCursorPositions());
18351
+ this._updateLine(lineIndex);
18352
+ currentLen = newHex.length;
18353
+ span.dataset.color = newHex;
18354
+ span.dataset.col = String(colStart);
18355
+ span.style.setProperty('--code-color', newHex);
18356
+ }
18357
+ });
18358
+ this._colorPopover = new exports.LX.Popover(span, [picker], { side: 'bottom', align: 'start', sideOffset: 4 });
18359
+ }
18360
+ // Symbol hover:
18361
+ /**
18362
+ * Extracts the parameter list from a function/method declaration line.
18363
+ */
18364
+ _getSymbolParams(symLine) {
18365
+ if (symLine < 0 || symLine >= this.doc.lineCount)
18366
+ return '()';
18367
+ const m = this.doc.getLine(symLine).match(/\(([^)]*)\)/);
18368
+ return m ? `(${m[1].trim()})` : '()';
18369
+ }
18370
+ /**
18371
+ * Starting from the line where a class is defined, scans forward to find
18372
+ * the constructor signature and returns its parameter list.
18373
+ */
18374
+ _findConstructorParams(classLine) {
18375
+ const classDepth = this.symbolTable.getScopeAtLine(classLine).length;
18376
+ const maxScan = Math.min(classLine + 50, this.doc.lineCount);
18377
+ for (let i = classLine + 1; i < maxScan; i++) {
18378
+ if (this.symbolTable.getScopeAtLine(i).length < classDepth + 1)
18379
+ break;
18380
+ const m = this.doc.getLine(i).match(/\bconstructor\s*\(([^)]*)\)/);
18381
+ if (m)
18382
+ return `(${m[1].trim()})`;
18383
+ }
18384
+ return null;
18385
+ }
18386
+ /**
18387
+ * Given multiple symbols with the same name, pick the most likely one for
18388
+ * the hovered position using the available context data.
18389
+ */
18390
+ _pickBestSymbol(symbols, word, tokenType, lineText, hoveredLine) {
18391
+ if (symbols.length === 1)
18392
+ return symbols[0];
18393
+ const curScopes = this.symbolTable.getScopeAtLine(hoveredLine);
18394
+ const curScope = [...curScopes].reverse().find(s => s.type !== 'global')?.name ?? 'global';
18395
+ const wordEsc = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
18396
+ const isNewCall = new RegExp(`\\bnew\\s+${wordEsc}\\b`).test(lineText);
18397
+ const isFuncCall = new RegExp(`\\b${wordEsc}\\s*[(<]`).test(lineText);
18398
+ const isTypeAnno = new RegExp(`(?::\\s*|<\\s*|[,>]\\s*)${wordEsc}\\b`).test(lineText);
18399
+ const typeKinds = new Set(['class', 'interface', 'type', 'enum', 'struct']);
18400
+ const funcKinds = new Set(['function', 'method']);
18401
+ const scored = symbols.map(sym => {
18402
+ let score = 0;
18403
+ // Defined in the current innermost scope
18404
+ if (sym.scope === curScope)
18405
+ score += 5;
18406
+ // Check token type
18407
+ if (tokenType === 'method' && funcKinds.has(sym.kind))
18408
+ score += 3;
18409
+ if (tokenType === 'type' && typeKinds.has(sym.kind))
18410
+ score += 3;
18411
+ // Hovered-line context patterns
18412
+ if (isNewCall && sym.kind === 'constructor-call')
18413
+ score += 20;
18414
+ if (isNewCall && sym.kind === 'class')
18415
+ score += 3;
18416
+ if (isFuncCall && funcKinds.has(sym.kind))
18417
+ score += 3;
18418
+ if (isTypeAnno && typeKinds.has(sym.kind))
18419
+ score += 2;
18420
+ // Validate kind based on symbol declaration line
18421
+ if (sym.line >= 0 && sym.line < this.doc.lineCount) {
18422
+ const declLine = this.doc.getLine(sym.line);
18423
+ if (/\bfunction\b/.test(declLine) && funcKinds.has(sym.kind))
18424
+ score += 4;
18425
+ if (/\bclass\b/.test(declLine) && sym.kind === 'class')
18426
+ score += 4;
18427
+ if (/\binterface\b/.test(declLine) && sym.kind === 'interface')
18428
+ score += 4;
18429
+ if (/\btype\b/.test(declLine) && sym.kind === 'type')
18430
+ score += 4;
18431
+ if (/\benum\b/.test(declLine) && sym.kind === 'enum')
18432
+ score += 4;
18433
+ if (/\b(?:const|let|var)\b/.test(declLine) && sym.kind === 'variable')
18434
+ score += 3;
18435
+ }
18436
+ // Order also by line proximity
18437
+ const dist = Math.abs(sym.line - hoveredLine);
18438
+ score += Math.max(0, 4 - Math.floor(dist / 20));
18439
+ return { sym, score };
18440
+ });
18441
+ scored.sort((a, b) => b.score - a.score);
18442
+ return scored[0].sym;
18443
+ }
18444
+ _clearHoverPopup() {
18445
+ if (this._hoverTimer) {
18446
+ clearTimeout(this._hoverTimer);
18447
+ this._hoverTimer = null;
18448
+ }
18449
+ if (this._hoverPopup) {
18450
+ this._hoverPopup.remove();
18451
+ this._hoverPopup = null;
18452
+ }
18453
+ this._hoverWord = '';
18454
+ }
18455
+ _onCodeAreaMouseMove(e) {
18456
+ if (!this.currentTab)
18457
+ return;
18458
+ // Only show hover when no button is pressed (no dragging)
18459
+ if (e.buttons !== 0) {
18460
+ this._clearHoverPopup();
18461
+ return;
18462
+ }
18463
+ const rect = this.codeContainer.getBoundingClientRect();
18464
+ const x = e.clientX - rect.left - this.xPadding;
18465
+ const y = e.clientY - rect.top;
18466
+ const line = exports.LX.clamp(Math.floor(y / this.lineHeight), 0, this.doc.lineCount - 1);
18467
+ const col = exports.LX.clamp(Math.round(x / this.charWidth), 0, this.doc.getLine(line).length);
18468
+ const [word, wordStart] = this.doc.getWordAt(line, col);
18469
+ if (!word || word === this._hoverWord)
18470
+ return;
18471
+ this._clearHoverPopup();
18472
+ if (!word.trim())
18473
+ return;
18474
+ this._hoverWord = word;
18475
+ this._hoverTimer = setTimeout(() => {
18476
+ this._hoverTimer = null;
18477
+ const prevState = line > 0 ? (this._lineStates[line - 1] ?? Tokenizer.initialState()) : Tokenizer.initialState();
18478
+ const { tokens } = Tokenizer.tokenizeLine(this.doc.getLine(line), this.language, prevState);
18479
+ let tokenType = 'text';
18480
+ let charPos = 0;
18481
+ for (const tok of tokens) {
18482
+ // Use wordStart (not col) so boundary positions like col=wordEnd don't fall into the next token
18483
+ if (wordStart >= charPos && wordStart < charPos + tok.value.length) {
18484
+ tokenType = tok.type;
18485
+ break;
18486
+ }
18487
+ charPos += tok.value.length;
18488
+ }
18489
+ if (tokenType === 'comment' || tokenType === 'string' || tokenType === 'number')
18490
+ return;
18491
+ const symbols = this.symbolTable.getSymbols(word);
18492
+ const info = { word, tokenType, symbols };
18493
+ let userContent;
18494
+ if (this.onHoverSymbol)
18495
+ userContent = this.onHoverSymbol(info, this);
18496
+ // No popup if user explicitly returns null
18497
+ if (userContent === null)
18498
+ return;
18499
+ const hasSymbols = symbols.length > 0;
18500
+ if (!userContent && !hasSymbols)
18501
+ return;
18502
+ const popup = this._hoverPopup = exports.LX.makeElement('div', 'code-hover-popup [&_span]:text-sm');
18503
+ if (userContent) {
18504
+ if (typeof userContent === 'string')
18505
+ popup.innerHTML = userContent;
18506
+ else
18507
+ popup.appendChild(userContent);
18508
+ }
18509
+ else {
18510
+ const sym = this._pickBestSymbol(symbols, word, tokenType, this.doc.getLine(line), line);
18511
+ let kindLabel = sym.kind;
18512
+ let nameLabel = `<span class="font-semibold">${word}</span>`;
18513
+ if (sym.kind === 'constructor-call') {
18514
+ const classSym = this.symbolTable.getSymbols(word).find(s => s.kind === 'class');
18515
+ const params = classSym != null ? (this._findConstructorParams(classSym.line) ?? '()') : '()';
18516
+ kindLabel = 'constructor';
18517
+ nameLabel = `${nameLabel}<span class="text-muted-foreground">${params}</span>`;
18518
+ }
18519
+ else if (sym.kind === 'function') {
18520
+ const params = this._getSymbolParams(sym.line);
18521
+ nameLabel = `${nameLabel}<span class="text-muted-foreground">${params}</span>`;
18522
+ }
18523
+ else if (sym.scope && sym.scope !== 'global' && sym.scope !== 'anonymous') {
18524
+ if (sym.kind === 'property' || sym.kind === 'method') {
18525
+ const scopePrefix = `<span class="text-muted-foreground">${sym.scope}.</span>`;
18526
+ if (sym.kind === 'method') {
18527
+ const params = this._getSymbolParams(sym.line);
18528
+ nameLabel = `${scopePrefix}${nameLabel}<span class="text-muted-foreground">${params}</span>`;
18529
+ }
18530
+ else {
18531
+ nameLabel = `${scopePrefix}${nameLabel}`;
18532
+ }
18533
+ }
18534
+ else if (sym.kind === 'variable') {
18535
+ // Only prefix the scope when the variable is a member (class/interface/struct field)
18536
+ const declScopes = this.symbolTable.getScopeAtLine(sym.line);
18537
+ const scopeEntry = declScopes.find(s => s.name === sym.scope);
18538
+ const memberTypes = new Set(['class', 'interface', 'struct']);
18539
+ if (scopeEntry && memberTypes.has(scopeEntry.type)) {
18540
+ nameLabel = `<span class="text-muted-foreground">${sym.scope}.</span>${nameLabel}`;
18541
+ }
18542
+ }
18543
+ }
18544
+ popup.innerHTML = `<span class="text-info">(${kindLabel})</span> ${nameLabel}`;
18545
+ }
18546
+ document.body.appendChild(popup);
18547
+ // Position just below the hovered word
18548
+ const lineEl = this._lineElements[line];
18549
+ if (lineEl) {
18550
+ const elRect = lineEl.getBoundingClientRect();
18551
+ const leftPx = elRect.left + this.xPadding + col * this.charWidth;
18552
+ const topPx = elRect.bottom + 4;
18553
+ popup.style.left = Math.min(leftPx, window.innerWidth - popup.offsetWidth - 8) + 'px';
18554
+ popup.style.top = topPx + 'px';
18555
+ }
18556
+ }, 500);
17758
18557
  }
17759
18558
  // Scrollbar & Resize:
17760
18559
  _scrollCursorIntoView() {