@design.estate/dees-catalog 3.65.0 → 3.66.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/dist_bundle/bundle.js +404 -148
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/00group-dataview/dees-table/dees-table.d.ts +71 -2
- package/dist_ts_web/elements/00group-dataview/dees-table/dees-table.demo.js +3 -2
- package/dist_ts_web/elements/00group-dataview/dees-table/dees-table.js +515 -154
- package/dist_watch/bundle.js +402 -146
- package/dist_watch/bundle.js.map +3 -3
- package/package.json +1 -1
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements/00group-dataview/dees-table/dees-table.demo.ts +2 -1
- package/ts_web/elements/00group-dataview/dees-table/dees-table.ts +519 -159
|
@@ -199,6 +199,21 @@ export class DeesTable<T> extends DeesElement {
|
|
|
199
199
|
@property({ type: Boolean, reflect: true, attribute: 'show-selection-checkbox' })
|
|
200
200
|
accessor showSelectionCheckbox: boolean = false;
|
|
201
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Enables row virtualization. Only rows visible in the nearest scroll
|
|
204
|
+
* ancestor (or the viewport) plus a small overscan are rendered. Top and
|
|
205
|
+
* bottom spacer rows preserve the scrollbar geometry.
|
|
206
|
+
*
|
|
207
|
+
* Assumes uniform row height (measured once from the first rendered row).
|
|
208
|
+
* Recommended for tables with > a few hundred rows.
|
|
209
|
+
*/
|
|
210
|
+
@property({ type: Boolean, reflect: true, attribute: 'virtualized' })
|
|
211
|
+
accessor virtualized: boolean = false;
|
|
212
|
+
|
|
213
|
+
/** Number of extra rows rendered above and below the visible window. */
|
|
214
|
+
@property({ type: Number, attribute: 'virtual-overscan' })
|
|
215
|
+
accessor virtualOverscan: number = 8;
|
|
216
|
+
|
|
202
217
|
/**
|
|
203
218
|
* When set, the table renders inside a fixed-height scroll container
|
|
204
219
|
* (`max-height: var(--table-max-height, 360px)`) and the header sticks
|
|
@@ -245,6 +260,46 @@ export class DeesTable<T> extends DeesElement {
|
|
|
245
260
|
@state()
|
|
246
261
|
private accessor __editingCell: { rowId: string; colKey: string } | undefined = undefined;
|
|
247
262
|
|
|
263
|
+
/**
|
|
264
|
+
* True while the page-sticky floating header overlay is visible. Lifted
|
|
265
|
+
* to @state so the floating-header clone subtree is rendered only when
|
|
266
|
+
* needed (saves a full thead worth of cells per render when inactive).
|
|
267
|
+
*/
|
|
268
|
+
@state()
|
|
269
|
+
private accessor __floatingActive: boolean = false;
|
|
270
|
+
|
|
271
|
+
// ─── Render memoization ──────────────────────────────────────────────
|
|
272
|
+
// These caches let render() short-circuit when the relevant inputs
|
|
273
|
+
// (by reference) haven't changed. They are NOT @state — mutating them
|
|
274
|
+
// must never trigger a re-render.
|
|
275
|
+
private __memoEffectiveCols?: {
|
|
276
|
+
columns: any;
|
|
277
|
+
augment: boolean;
|
|
278
|
+
displayFunction: any;
|
|
279
|
+
data: any;
|
|
280
|
+
out: Column<T>[];
|
|
281
|
+
};
|
|
282
|
+
private __memoViewData?: {
|
|
283
|
+
data: any;
|
|
284
|
+
sortBy: any;
|
|
285
|
+
filterText: string;
|
|
286
|
+
columnFilters: any;
|
|
287
|
+
searchMode: string;
|
|
288
|
+
effectiveColumns: Column<T>[];
|
|
289
|
+
out: T[];
|
|
290
|
+
};
|
|
291
|
+
/** Tracks the (data, columns) pair that `determineColumnWidths()` last sized for. */
|
|
292
|
+
private __columnsSizedFor?: { data: any; columns: any };
|
|
293
|
+
|
|
294
|
+
// ─── Virtualization state ────────────────────────────────────────────
|
|
295
|
+
/** Estimated row height (px). Measured once from the first rendered row. */
|
|
296
|
+
private __rowHeight: number = 36;
|
|
297
|
+
/** True once we've measured `__rowHeight` from a real DOM row. */
|
|
298
|
+
private __rowHeightMeasured: boolean = false;
|
|
299
|
+
/** Currently rendered range [start, end). Triggers re-render when changed. */
|
|
300
|
+
@state()
|
|
301
|
+
private accessor __virtualRange: { start: number; end: number } = { start: 0, end: 0 };
|
|
302
|
+
|
|
248
303
|
constructor() {
|
|
249
304
|
super();
|
|
250
305
|
// Make the host focusable so it can receive Ctrl/Cmd+C for copy.
|
|
@@ -368,28 +423,106 @@ export class DeesTable<T> extends DeesElement {
|
|
|
368
423
|
|
|
369
424
|
public static styles = tableStyles;
|
|
370
425
|
|
|
371
|
-
|
|
426
|
+
/**
|
|
427
|
+
* Returns the effective column schema, memoized by reference of the inputs
|
|
428
|
+
* that affect it. Avoids re-running `computeEffectiveColumnsFn` /
|
|
429
|
+
* `computeColumnsFromDisplayFunctionFn` on every Lit update.
|
|
430
|
+
*/
|
|
431
|
+
private __getEffectiveColumns(): Column<T>[] {
|
|
372
432
|
const usingColumns = Array.isArray(this.columns) && this.columns.length > 0;
|
|
373
|
-
const
|
|
374
|
-
|
|
433
|
+
const cache = this.__memoEffectiveCols;
|
|
434
|
+
if (
|
|
435
|
+
cache &&
|
|
436
|
+
cache.columns === this.columns &&
|
|
437
|
+
cache.augment === this.augmentFromDisplayFunction &&
|
|
438
|
+
cache.displayFunction === this.displayFunction &&
|
|
439
|
+
cache.data === this.data
|
|
440
|
+
) {
|
|
441
|
+
return cache.out;
|
|
442
|
+
}
|
|
443
|
+
const out = usingColumns
|
|
444
|
+
? computeEffectiveColumnsFn(
|
|
445
|
+
this.columns,
|
|
446
|
+
this.augmentFromDisplayFunction,
|
|
447
|
+
this.displayFunction,
|
|
448
|
+
this.data
|
|
449
|
+
)
|
|
375
450
|
: computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
|
|
451
|
+
this.__memoEffectiveCols = {
|
|
452
|
+
columns: this.columns,
|
|
453
|
+
augment: this.augmentFromDisplayFunction,
|
|
454
|
+
displayFunction: this.displayFunction,
|
|
455
|
+
data: this.data,
|
|
456
|
+
out,
|
|
457
|
+
};
|
|
458
|
+
return out;
|
|
459
|
+
}
|
|
376
460
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const
|
|
461
|
+
/**
|
|
462
|
+
* Returns the sorted/filtered view of the data, memoized by reference of
|
|
463
|
+
* everything that affects it. Avoids re-running the lucene compiler and
|
|
464
|
+
* the sort/filter pipeline on every render.
|
|
465
|
+
*/
|
|
466
|
+
private __getViewData(effectiveColumns: Column<T>[]): T[] {
|
|
467
|
+
const searchMode = this.searchMode === 'data' ? 'data' : 'table';
|
|
468
|
+
const cache = this.__memoViewData;
|
|
469
|
+
if (
|
|
470
|
+
cache &&
|
|
471
|
+
cache.data === this.data &&
|
|
472
|
+
cache.sortBy === this.sortBy &&
|
|
473
|
+
cache.filterText === this.filterText &&
|
|
474
|
+
cache.columnFilters === this.columnFilters &&
|
|
475
|
+
cache.searchMode === searchMode &&
|
|
476
|
+
cache.effectiveColumns === effectiveColumns
|
|
477
|
+
) {
|
|
478
|
+
return cache.out;
|
|
479
|
+
}
|
|
480
|
+
const lucenePred = compileLucenePredicate<T>(this.filterText, searchMode, effectiveColumns);
|
|
481
|
+
const out = getViewDataFn(
|
|
384
482
|
this.data,
|
|
385
483
|
effectiveColumns,
|
|
386
484
|
this.sortBy,
|
|
387
485
|
this.filterText,
|
|
388
486
|
this.columnFilters,
|
|
389
|
-
|
|
487
|
+
searchMode,
|
|
390
488
|
lucenePred || undefined
|
|
391
489
|
);
|
|
490
|
+
this.__memoViewData = {
|
|
491
|
+
data: this.data,
|
|
492
|
+
sortBy: this.sortBy,
|
|
493
|
+
filterText: this.filterText,
|
|
494
|
+
columnFilters: this.columnFilters,
|
|
495
|
+
searchMode,
|
|
496
|
+
effectiveColumns,
|
|
497
|
+
out,
|
|
498
|
+
};
|
|
499
|
+
return out;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
public render(): TemplateResult {
|
|
503
|
+
const effectiveColumns = this.__getEffectiveColumns();
|
|
504
|
+
const viewData = this.__getViewData(effectiveColumns);
|
|
392
505
|
(this as any)._lastViewData = viewData;
|
|
506
|
+
|
|
507
|
+
// Virtualization slice — only the rows in `__virtualRange` actually
|
|
508
|
+
// render. Top/bottom spacer rows preserve scroll geometry.
|
|
509
|
+
const useVirtual = this.virtualized && viewData.length > 0;
|
|
510
|
+
let renderRows: T[] = viewData;
|
|
511
|
+
let renderStart = 0;
|
|
512
|
+
let topSpacerHeight = 0;
|
|
513
|
+
let bottomSpacerHeight = 0;
|
|
514
|
+
if (useVirtual) {
|
|
515
|
+
const range = this.__virtualRange;
|
|
516
|
+
const start = Math.max(0, range.start);
|
|
517
|
+
const end = Math.min(viewData.length, range.end || 0);
|
|
518
|
+
// On the very first render the range is {0,0} — render a small first
|
|
519
|
+
// window so we can measure row height and compute the real range.
|
|
520
|
+
const initialEnd = end > 0 ? end : Math.min(viewData.length, this.virtualOverscan * 2 + 16);
|
|
521
|
+
renderStart = start;
|
|
522
|
+
renderRows = viewData.slice(start, initialEnd);
|
|
523
|
+
topSpacerHeight = start * this.__rowHeight;
|
|
524
|
+
bottomSpacerHeight = Math.max(0, viewData.length - initialEnd) * this.__rowHeight;
|
|
525
|
+
}
|
|
393
526
|
return html`
|
|
394
527
|
<dees-tile>
|
|
395
528
|
<div slot="header" class="header">
|
|
@@ -460,98 +593,25 @@ export class DeesTable<T> extends DeesElement {
|
|
|
460
593
|
<thead>
|
|
461
594
|
${this.renderHeaderRows(effectiveColumns)}
|
|
462
595
|
</thead>
|
|
463
|
-
<tbody
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
596
|
+
<tbody
|
|
597
|
+
@click=${this.__onTbodyClick}
|
|
598
|
+
@dblclick=${this.__onTbodyDblclick}
|
|
599
|
+
@mousedown=${this.__onTbodyMousedown}
|
|
600
|
+
@contextmenu=${this.__onTbodyContextmenu}
|
|
601
|
+
@dragenter=${this.__onTbodyDragenter}
|
|
602
|
+
@dragleave=${this.__onTbodyDragleave}
|
|
603
|
+
@dragover=${this.__onTbodyDragover}
|
|
604
|
+
@drop=${this.__onTbodyDrop}
|
|
605
|
+
>
|
|
606
|
+
${useVirtual && topSpacerHeight > 0
|
|
607
|
+
? html`<tr aria-hidden="true" style="height:${topSpacerHeight}px"><td></td></tr>`
|
|
608
|
+
: html``}
|
|
609
|
+
${renderRows.map((itemArg, sliceIdx) => {
|
|
610
|
+
const rowIndex = renderStart + sliceIdx;
|
|
611
|
+
const rowId = this.getRowId(itemArg);
|
|
472
612
|
return html`
|
|
473
613
|
<tr
|
|
474
|
-
|
|
475
|
-
@mousedown=${(e: MouseEvent) => {
|
|
476
|
-
// Prevent the browser's native shift-click text
|
|
477
|
-
// selection so range-select doesn't highlight text.
|
|
478
|
-
if (e.shiftKey && this.selectionMode !== 'single') e.preventDefault();
|
|
479
|
-
}}
|
|
480
|
-
@dragenter=${async (eventArg: DragEvent) => {
|
|
481
|
-
eventArg.preventDefault();
|
|
482
|
-
eventArg.stopPropagation();
|
|
483
|
-
const realTarget = getTr(eventArg.target as HTMLElement);
|
|
484
|
-
setTimeout(() => {
|
|
485
|
-
realTarget.classList.add('hasAttachment');
|
|
486
|
-
}, 0);
|
|
487
|
-
}}
|
|
488
|
-
@dragleave=${async (eventArg: DragEvent) => {
|
|
489
|
-
eventArg.preventDefault();
|
|
490
|
-
eventArg.stopPropagation();
|
|
491
|
-
const realTarget = getTr(eventArg.target as HTMLElement);
|
|
492
|
-
realTarget.classList.remove('hasAttachment');
|
|
493
|
-
}}
|
|
494
|
-
@dragover=${async (eventArg: DragEvent) => {
|
|
495
|
-
eventArg.preventDefault();
|
|
496
|
-
}}
|
|
497
|
-
@drop=${async (eventArg: DragEvent) => {
|
|
498
|
-
eventArg.preventDefault();
|
|
499
|
-
const newFiles: File[] = [];
|
|
500
|
-
for (const file of Array.from(eventArg.dataTransfer!.files)) {
|
|
501
|
-
this.files.push(file);
|
|
502
|
-
newFiles.push(file);
|
|
503
|
-
this.requestUpdate();
|
|
504
|
-
}
|
|
505
|
-
const result: File[] = this.fileWeakMap.get(itemArg as object);
|
|
506
|
-
if (!result) {
|
|
507
|
-
this.fileWeakMap.set(itemArg as object, newFiles);
|
|
508
|
-
} else {
|
|
509
|
-
result.push(...newFiles);
|
|
510
|
-
}
|
|
511
|
-
}}
|
|
512
|
-
@contextmenu=${async (eventArg: MouseEvent) => {
|
|
513
|
-
// If the right-clicked row isn't part of the
|
|
514
|
-
// current selection, treat it like a plain click
|
|
515
|
-
// first so the context menu acts on a sensible
|
|
516
|
-
// selection (matches file-manager behavior).
|
|
517
|
-
if (!this.isRowSelected(itemArg)) {
|
|
518
|
-
this.selectedDataRow = itemArg;
|
|
519
|
-
this.selectedIds.clear();
|
|
520
|
-
this.selectedIds.add(this.getRowId(itemArg));
|
|
521
|
-
this.__selectionAnchorId = this.getRowId(itemArg);
|
|
522
|
-
this.emitSelectionChange();
|
|
523
|
-
this.requestUpdate();
|
|
524
|
-
}
|
|
525
|
-
const userItems: plugins.tsclass.website.IMenuItem[] =
|
|
526
|
-
this.getActionsForType('contextmenu').map((action) => ({
|
|
527
|
-
name: action.name,
|
|
528
|
-
iconName: action.iconName as any,
|
|
529
|
-
action: async () => {
|
|
530
|
-
await action.actionFunc({
|
|
531
|
-
item: itemArg,
|
|
532
|
-
table: this,
|
|
533
|
-
});
|
|
534
|
-
return null;
|
|
535
|
-
},
|
|
536
|
-
}));
|
|
537
|
-
const defaultItems: plugins.tsclass.website.IMenuItem[] = [
|
|
538
|
-
{
|
|
539
|
-
name:
|
|
540
|
-
this.selectedIds.size > 1
|
|
541
|
-
? `Copy ${this.selectedIds.size} rows as JSON`
|
|
542
|
-
: 'Copy row as JSON',
|
|
543
|
-
iconName: 'lucide:Copy' as any,
|
|
544
|
-
action: async () => {
|
|
545
|
-
this.copySelectionAsJson(itemArg);
|
|
546
|
-
return null;
|
|
547
|
-
},
|
|
548
|
-
},
|
|
549
|
-
];
|
|
550
|
-
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
|
551
|
-
...userItems,
|
|
552
|
-
...defaultItems,
|
|
553
|
-
]);
|
|
554
|
-
}}
|
|
614
|
+
data-row-idx=${rowIndex}
|
|
555
615
|
class="${itemArg === this.selectedDataRow || this.isRowSelected(itemArg) ? 'selected' : ''}"
|
|
556
616
|
>
|
|
557
617
|
${this.showSelectionCheckbox
|
|
@@ -574,7 +634,6 @@ export class DeesTable<T> extends DeesElement {
|
|
|
574
634
|
: value;
|
|
575
635
|
const editKey = String(col.key);
|
|
576
636
|
const isEditable = !!(col.editable || col.editor);
|
|
577
|
-
const rowId = this.getRowId(itemArg);
|
|
578
637
|
const isFocused =
|
|
579
638
|
this.__focusedCell?.rowId === rowId &&
|
|
580
639
|
this.__focusedCell?.colKey === editKey;
|
|
@@ -591,26 +650,7 @@ export class DeesTable<T> extends DeesElement {
|
|
|
591
650
|
return html`
|
|
592
651
|
<td
|
|
593
652
|
class=${cellClasses}
|
|
594
|
-
|
|
595
|
-
if (isEditing) {
|
|
596
|
-
e.stopPropagation();
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
if (isEditable) {
|
|
600
|
-
this.__focusedCell = { rowId, colKey: editKey };
|
|
601
|
-
}
|
|
602
|
-
}}
|
|
603
|
-
@dblclick=${(e: Event) => {
|
|
604
|
-
const dblAction = this.dataActions.find((actionArg) =>
|
|
605
|
-
actionArg.type?.includes('doubleClick')
|
|
606
|
-
);
|
|
607
|
-
if (isEditable) {
|
|
608
|
-
e.stopPropagation();
|
|
609
|
-
this.startEditing(itemArg, col);
|
|
610
|
-
} else if (dblAction) {
|
|
611
|
-
dblAction.actionFunc({ item: itemArg, table: this });
|
|
612
|
-
}
|
|
613
|
-
}}
|
|
653
|
+
data-col-key=${editKey}
|
|
614
654
|
>
|
|
615
655
|
<div class="innerCellContainer">
|
|
616
656
|
${isEditing ? this.renderCellEditor(itemArg, col) : content}
|
|
@@ -646,15 +686,20 @@ export class DeesTable<T> extends DeesElement {
|
|
|
646
686
|
})()}
|
|
647
687
|
</tr>`;
|
|
648
688
|
})}
|
|
689
|
+
${useVirtual && bottomSpacerHeight > 0
|
|
690
|
+
? html`<tr aria-hidden="true" style="height:${bottomSpacerHeight}px"><td></td></tr>`
|
|
691
|
+
: html``}
|
|
649
692
|
</tbody>
|
|
650
693
|
</table>
|
|
651
694
|
</div>
|
|
652
695
|
<div class="floatingHeader" aria-hidden="true">
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
696
|
+
${this.__floatingActive
|
|
697
|
+
? html`<table>
|
|
698
|
+
<thead>
|
|
699
|
+
${this.renderHeaderRows(effectiveColumns)}
|
|
700
|
+
</thead>
|
|
701
|
+
</table>`
|
|
702
|
+
: html``}
|
|
658
703
|
</div>
|
|
659
704
|
`
|
|
660
705
|
: html` <div class="noDataSet">No data set!</div> `}
|
|
@@ -771,7 +816,8 @@ export class DeesTable<T> extends DeesElement {
|
|
|
771
816
|
// ─── Floating header (page-sticky) lifecycle ─────────────────────────
|
|
772
817
|
private __floatingResizeObserver?: ResizeObserver;
|
|
773
818
|
private __floatingScrollHandler?: () => void;
|
|
774
|
-
|
|
819
|
+
// __floatingActive is declared as a @state field above so its toggle
|
|
820
|
+
// triggers re-rendering of the floating-header clone subtree.
|
|
775
821
|
private __scrollAncestors: Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> = [];
|
|
776
822
|
|
|
777
823
|
private get __floatingHeaderEl(): HTMLDivElement | null {
|
|
@@ -854,32 +900,45 @@ export class DeesTable<T> extends DeesElement {
|
|
|
854
900
|
|
|
855
901
|
private setupFloatingHeader() {
|
|
856
902
|
this.teardownFloatingHeader();
|
|
857
|
-
|
|
903
|
+
// Skip entirely only when neither feature needs scroll watchers.
|
|
904
|
+
if (this.fixedHeight && !this.virtualized) return;
|
|
858
905
|
const realTable = this.__realTableEl;
|
|
859
906
|
if (!realTable) return;
|
|
860
907
|
|
|
861
908
|
this.__scrollAncestors = this.__collectScrollAncestors();
|
|
862
909
|
// .tableScroll is a descendant (inside our shadow root), not an ancestor,
|
|
863
|
-
// so the upward walk above misses it. Add it explicitly
|
|
864
|
-
//
|
|
910
|
+
// so the upward walk above misses it. Add it explicitly. In Mode A
|
|
911
|
+
// (`fixedHeight`) it is the only vertical scroll source — mark it as
|
|
912
|
+
// scrollsY in that case so virtualization picks it up.
|
|
865
913
|
const tableScrollEl = this.shadowRoot?.querySelector('.tableScroll') as HTMLElement | null;
|
|
866
914
|
if (tableScrollEl) {
|
|
867
|
-
this.__scrollAncestors.unshift({
|
|
915
|
+
this.__scrollAncestors.unshift({
|
|
916
|
+
target: tableScrollEl,
|
|
917
|
+
scrollsY: this.fixedHeight,
|
|
918
|
+
scrollsX: true,
|
|
919
|
+
});
|
|
868
920
|
}
|
|
869
921
|
|
|
870
922
|
// Track resize of the real table so we can mirror its width and column widths.
|
|
871
923
|
this.__floatingResizeObserver = new ResizeObserver(() => {
|
|
872
|
-
this.__syncFloatingHeader();
|
|
924
|
+
if (!this.fixedHeight) this.__syncFloatingHeader();
|
|
925
|
+
if (this.virtualized) this.__computeVirtualRange();
|
|
873
926
|
});
|
|
874
927
|
this.__floatingResizeObserver.observe(realTable);
|
|
875
928
|
|
|
876
|
-
this.__floatingScrollHandler = () =>
|
|
929
|
+
this.__floatingScrollHandler = () => {
|
|
930
|
+
if (!this.fixedHeight) this.__syncFloatingHeader();
|
|
931
|
+
// Recompute virtual range on every scroll — cheap (one rect read +
|
|
932
|
+
// some math) and necessary so rows materialize before they're seen.
|
|
933
|
+
if (this.virtualized) this.__computeVirtualRange();
|
|
934
|
+
};
|
|
877
935
|
for (const a of this.__scrollAncestors) {
|
|
878
936
|
a.target.addEventListener('scroll', this.__floatingScrollHandler, { passive: true });
|
|
879
937
|
}
|
|
880
938
|
window.addEventListener('resize', this.__floatingScrollHandler, { passive: true });
|
|
881
939
|
|
|
882
|
-
this.__syncFloatingHeader();
|
|
940
|
+
if (!this.fixedHeight) this.__syncFloatingHeader();
|
|
941
|
+
if (this.virtualized) this.__computeVirtualRange();
|
|
883
942
|
}
|
|
884
943
|
|
|
885
944
|
private teardownFloatingHeader() {
|
|
@@ -898,35 +957,99 @@ export class DeesTable<T> extends DeesElement {
|
|
|
898
957
|
if (fh) fh.classList.remove('active');
|
|
899
958
|
}
|
|
900
959
|
|
|
960
|
+
// ─── Virtualization ─────────────────────────────────────────────────
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Computes the visible row range based on the table's position in its
|
|
964
|
+
* nearest vertical scroll ancestor (or the viewport). Updates
|
|
965
|
+
* `__virtualRange` if it changed; that triggers a Lit re-render.
|
|
966
|
+
*/
|
|
967
|
+
private __computeVirtualRange() {
|
|
968
|
+
if (!this.virtualized) return;
|
|
969
|
+
const view: T[] = (this as any)._lastViewData ?? [];
|
|
970
|
+
const total = view.length;
|
|
971
|
+
if (total === 0) {
|
|
972
|
+
if (this.__virtualRange.start !== 0 || this.__virtualRange.end !== 0) {
|
|
973
|
+
this.__virtualRange = { start: 0, end: 0 };
|
|
974
|
+
}
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
const realTable = this.__realTableEl;
|
|
978
|
+
if (!realTable) return;
|
|
979
|
+
const tableRect = realTable.getBoundingClientRect();
|
|
980
|
+
|
|
981
|
+
// Find the innermost vertical scroll ancestor (rect + content height).
|
|
982
|
+
let viewportTop = 0;
|
|
983
|
+
let viewportBottom = window.innerHeight;
|
|
984
|
+
for (const a of this.__scrollAncestors) {
|
|
985
|
+
if (a.target === window || !a.scrollsY) continue;
|
|
986
|
+
const r = (a.target as Element).getBoundingClientRect();
|
|
987
|
+
const cs = getComputedStyle(a.target as Element);
|
|
988
|
+
const bt = parseFloat(cs.borderTopWidth) || 0;
|
|
989
|
+
const bb = parseFloat(cs.borderBottomWidth) || 0;
|
|
990
|
+
viewportTop = Math.max(viewportTop, r.top + bt);
|
|
991
|
+
viewportBottom = Math.min(viewportBottom, r.bottom - bb);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const rowH = Math.max(1, this.__rowHeight);
|
|
995
|
+
// Distance from the table top to the visible window top, in px of body
|
|
996
|
+
// content (so any header offset above the rows is excluded).
|
|
997
|
+
const headerHeight = realTable.tHead?.getBoundingClientRect().height ?? 0;
|
|
998
|
+
const bodyTop = tableRect.top + headerHeight;
|
|
999
|
+
const offsetIntoBody = Math.max(0, viewportTop - bodyTop);
|
|
1000
|
+
const visiblePx = Math.max(0, viewportBottom - Math.max(viewportTop, bodyTop));
|
|
1001
|
+
|
|
1002
|
+
const startRaw = Math.floor(offsetIntoBody / rowH);
|
|
1003
|
+
const visibleCount = Math.ceil(visiblePx / rowH) + 1;
|
|
1004
|
+
const start = Math.max(0, startRaw - this.virtualOverscan);
|
|
1005
|
+
const end = Math.min(total, startRaw + visibleCount + this.virtualOverscan);
|
|
1006
|
+
|
|
1007
|
+
if (start !== this.__virtualRange.start || end !== this.__virtualRange.end) {
|
|
1008
|
+
this.__virtualRange = { start, end };
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Measures the height of the first rendered body row and stores it for
|
|
1014
|
+
* subsequent virtualization math. Idempotent — only measures once per
|
|
1015
|
+
* `data`/`columns` pair (cleared in `updated()` when those change).
|
|
1016
|
+
*/
|
|
1017
|
+
private __measureRowHeight() {
|
|
1018
|
+
if (!this.virtualized || this.__rowHeightMeasured) return;
|
|
1019
|
+
const tbody = this.shadowRoot?.querySelector('tbody') as HTMLTableSectionElement | null;
|
|
1020
|
+
if (!tbody) return;
|
|
1021
|
+
const firstRow = Array.from(tbody.rows).find((r) => r.hasAttribute('data-row-idx'));
|
|
1022
|
+
if (!firstRow) return;
|
|
1023
|
+
const h = firstRow.getBoundingClientRect().height;
|
|
1024
|
+
if (h > 0) {
|
|
1025
|
+
this.__rowHeight = h;
|
|
1026
|
+
this.__rowHeightMeasured = true;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
901
1030
|
/**
|
|
902
1031
|
* Single function that drives both activation and geometry of the floating
|
|
903
|
-
* header. Called on scroll, resize, table-resize, and after
|
|
1032
|
+
* header. Called on scroll, resize, table-resize, and after relevant
|
|
1033
|
+
* renders.
|
|
1034
|
+
*
|
|
1035
|
+
* Activation is decided from the *real* header geometry, so this function
|
|
1036
|
+
* works even when the clone subtree hasn't been rendered yet (it's only
|
|
1037
|
+
* rendered when `__floatingActive` is true). The first activation flips
|
|
1038
|
+
* `__floatingActive`; the next render materializes the clone; the next
|
|
1039
|
+
* call here mirrors widths and positions.
|
|
904
1040
|
*/
|
|
905
1041
|
private __syncFloatingHeader() {
|
|
906
1042
|
const fh = this.__floatingHeaderEl;
|
|
907
1043
|
const realTable = this.__realTableEl;
|
|
908
|
-
|
|
909
|
-
if (!fh || !realTable || !floatTable) return;
|
|
1044
|
+
if (!fh || !realTable) return;
|
|
910
1045
|
|
|
911
1046
|
const tableRect = realTable.getBoundingClientRect();
|
|
912
1047
|
const stick = this.__getStickContext();
|
|
913
|
-
|
|
914
|
-
// Mirror table layout + per-cell widths so columns line up.
|
|
915
|
-
floatTable.style.tableLayout = realTable.style.tableLayout || 'auto';
|
|
916
1048
|
const realHeadRows = realTable.tHead?.rows;
|
|
917
|
-
const floatHeadRows = floatTable.tHead?.rows;
|
|
918
1049
|
let headerHeight = 0;
|
|
919
|
-
if (realHeadRows
|
|
920
|
-
for (let r = 0; r < realHeadRows.length
|
|
1050
|
+
if (realHeadRows) {
|
|
1051
|
+
for (let r = 0; r < realHeadRows.length; r++) {
|
|
921
1052
|
headerHeight += realHeadRows[r].getBoundingClientRect().height;
|
|
922
|
-
const realCells = realHeadRows[r].cells;
|
|
923
|
-
const floatCells = floatHeadRows[r].cells;
|
|
924
|
-
for (let c = 0; c < realCells.length && c < floatCells.length; c++) {
|
|
925
|
-
const w = realCells[c].getBoundingClientRect().width;
|
|
926
|
-
(floatCells[c] as HTMLElement).style.width = `${w}px`;
|
|
927
|
-
(floatCells[c] as HTMLElement).style.minWidth = `${w}px`;
|
|
928
|
-
(floatCells[c] as HTMLElement).style.maxWidth = `${w}px`;
|
|
929
|
-
}
|
|
930
1053
|
}
|
|
931
1054
|
}
|
|
932
1055
|
|
|
@@ -938,9 +1061,34 @@ export class DeesTable<T> extends DeesElement {
|
|
|
938
1061
|
if (shouldBeActive !== this.__floatingActive) {
|
|
939
1062
|
this.__floatingActive = shouldBeActive;
|
|
940
1063
|
fh.classList.toggle('active', shouldBeActive);
|
|
1064
|
+
if (shouldBeActive) {
|
|
1065
|
+
// Clone subtree doesn't exist yet — wait for the next render to
|
|
1066
|
+
// materialize it, then complete geometry sync.
|
|
1067
|
+
this.updateComplete.then(() => this.__syncFloatingHeader());
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
941
1070
|
}
|
|
942
1071
|
if (!shouldBeActive) return;
|
|
943
1072
|
|
|
1073
|
+
// Mirror table layout + per-cell widths so columns line up. The clone
|
|
1074
|
+
// exists at this point because __floatingActive === true.
|
|
1075
|
+
const floatTable = this.__floatingTableEl;
|
|
1076
|
+
if (!floatTable) return;
|
|
1077
|
+
floatTable.style.tableLayout = realTable.style.tableLayout || 'auto';
|
|
1078
|
+
const floatHeadRows = floatTable.tHead?.rows;
|
|
1079
|
+
if (realHeadRows && floatHeadRows) {
|
|
1080
|
+
for (let r = 0; r < realHeadRows.length && r < floatHeadRows.length; r++) {
|
|
1081
|
+
const realCells = realHeadRows[r].cells;
|
|
1082
|
+
const floatCells = floatHeadRows[r].cells;
|
|
1083
|
+
for (let c = 0; c < realCells.length && c < floatCells.length; c++) {
|
|
1084
|
+
const w = realCells[c].getBoundingClientRect().width;
|
|
1085
|
+
(floatCells[c] as HTMLElement).style.width = `${w}px`;
|
|
1086
|
+
(floatCells[c] as HTMLElement).style.minWidth = `${w}px`;
|
|
1087
|
+
(floatCells[c] as HTMLElement).style.maxWidth = `${w}px`;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
944
1092
|
// Position the floating header. Clip horizontally to the scroll context
|
|
945
1093
|
// so a horizontally-scrolled inner container's header doesn't bleed
|
|
946
1094
|
// outside the container's border.
|
|
@@ -970,24 +1118,55 @@ export class DeesTable<T> extends DeesElement {
|
|
|
970
1118
|
|
|
971
1119
|
public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
|
|
972
1120
|
super.updated(changedProperties);
|
|
973
|
-
|
|
974
|
-
//
|
|
975
|
-
//
|
|
1121
|
+
|
|
1122
|
+
// Only re-measure column widths when the data or schema actually changed
|
|
1123
|
+
// (or on first paint). `determineColumnWidths` is the single biggest
|
|
1124
|
+
// first-paint cost — it forces multiple layout flushes per row.
|
|
1125
|
+
const dataOrColsChanged =
|
|
1126
|
+
!this.__columnsSizedFor ||
|
|
1127
|
+
this.__columnsSizedFor.data !== this.data ||
|
|
1128
|
+
this.__columnsSizedFor.columns !== this.columns;
|
|
1129
|
+
if (dataOrColsChanged) {
|
|
1130
|
+
this.__columnsSizedFor = { data: this.data, columns: this.columns };
|
|
1131
|
+
this.determineColumnWidths();
|
|
1132
|
+
// Force re-measure of row height; structure may have changed.
|
|
1133
|
+
this.__rowHeightMeasured = false;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Virtualization: measure row height after the first paint with rows,
|
|
1137
|
+
// then compute the visible range. Both ops only run when `virtualized`
|
|
1138
|
+
// is true, so the cost is zero for normal tables.
|
|
1139
|
+
if (this.virtualized) {
|
|
1140
|
+
this.__measureRowHeight();
|
|
1141
|
+
this.__computeVirtualRange();
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// (Re)wire the scroll watchers (used by both the floating header in
|
|
1145
|
+
// Mode B and by virtualization). Skip entirely only when neither
|
|
1146
|
+
// feature needs them.
|
|
976
1147
|
if (
|
|
977
1148
|
changedProperties.has('fixedHeight') ||
|
|
1149
|
+
changedProperties.has('virtualized') ||
|
|
978
1150
|
changedProperties.has('data') ||
|
|
979
1151
|
changedProperties.has('columns') ||
|
|
980
1152
|
!this.__floatingScrollHandler
|
|
981
1153
|
) {
|
|
982
|
-
|
|
1154
|
+
const needsScrollWatchers = (!this.fixedHeight || this.virtualized) && this.data.length > 0;
|
|
1155
|
+
if (needsScrollWatchers) {
|
|
983
1156
|
this.setupFloatingHeader();
|
|
984
1157
|
} else {
|
|
985
1158
|
this.teardownFloatingHeader();
|
|
986
1159
|
}
|
|
987
1160
|
}
|
|
988
|
-
//
|
|
989
|
-
//
|
|
990
|
-
|
|
1161
|
+
// Only sync the floating header geometry when it's actually showing or
|
|
1162
|
+
// the table layout-affecting state changed. Avoids per-render layout
|
|
1163
|
+
// reads (getBoundingClientRect on every header cell) for typical updates
|
|
1164
|
+
// like sort changes or selection toggles.
|
|
1165
|
+
if (
|
|
1166
|
+
!this.fixedHeight &&
|
|
1167
|
+
this.data.length > 0 &&
|
|
1168
|
+
(this.__floatingActive || dataOrColsChanged)
|
|
1169
|
+
) {
|
|
991
1170
|
this.__syncFloatingHeader();
|
|
992
1171
|
}
|
|
993
1172
|
if (this.searchable) {
|
|
@@ -1502,6 +1681,187 @@ export class DeesTable<T> extends DeesElement {
|
|
|
1502
1681
|
this.requestUpdate();
|
|
1503
1682
|
}
|
|
1504
1683
|
|
|
1684
|
+
// ─── Delegated tbody event handlers ─────────────────────────────────
|
|
1685
|
+
// Hoisted from per-<tr> closures to a single set of handlers on <tbody>.
|
|
1686
|
+
// Cuts ~7 closure allocations per row per render. Each handler resolves
|
|
1687
|
+
// the source row via `data-row-idx` (and `data-col-key` for cell-level
|
|
1688
|
+
// events) using the latest `_lastViewData`.
|
|
1689
|
+
|
|
1690
|
+
private __resolveRow(eventArg: Event): { item: T; rowIdx: number } | null {
|
|
1691
|
+
const path = (eventArg.composedPath?.() || []) as EventTarget[];
|
|
1692
|
+
let tr: HTMLTableRowElement | null = null;
|
|
1693
|
+
for (const t of path) {
|
|
1694
|
+
const el = t as HTMLElement;
|
|
1695
|
+
if (el?.tagName === 'TR' && el.hasAttribute('data-row-idx')) {
|
|
1696
|
+
tr = el as HTMLTableRowElement;
|
|
1697
|
+
break;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
if (!tr) return null;
|
|
1701
|
+
const rowIdx = Number(tr.getAttribute('data-row-idx'));
|
|
1702
|
+
const view: T[] = (this as any)._lastViewData ?? [];
|
|
1703
|
+
const item = view[rowIdx];
|
|
1704
|
+
if (!item) return null;
|
|
1705
|
+
return { item, rowIdx };
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
private __resolveCell(eventArg: Event): { item: T; rowIdx: number; col: Column<T> } | null {
|
|
1709
|
+
const row = this.__resolveRow(eventArg);
|
|
1710
|
+
if (!row) return null;
|
|
1711
|
+
const path = (eventArg.composedPath?.() || []) as EventTarget[];
|
|
1712
|
+
let td: HTMLTableCellElement | null = null;
|
|
1713
|
+
for (const t of path) {
|
|
1714
|
+
const el = t as HTMLElement;
|
|
1715
|
+
if (el?.tagName === 'TD' && el.hasAttribute('data-col-key')) {
|
|
1716
|
+
td = el as HTMLTableCellElement;
|
|
1717
|
+
break;
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
if (!td) return null;
|
|
1721
|
+
const colKey = td.getAttribute('data-col-key')!;
|
|
1722
|
+
const cols = this.__getEffectiveColumns();
|
|
1723
|
+
const col = cols.find((c) => String(c.key) === colKey);
|
|
1724
|
+
if (!col) return null;
|
|
1725
|
+
return { item: row.item, rowIdx: row.rowIdx, col };
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
private __isInActionsCol(eventArg: Event): boolean {
|
|
1729
|
+
const path = (eventArg.composedPath?.() || []) as EventTarget[];
|
|
1730
|
+
for (const t of path) {
|
|
1731
|
+
const el = t as HTMLElement;
|
|
1732
|
+
if (el?.classList?.contains('actionsCol')) return true;
|
|
1733
|
+
}
|
|
1734
|
+
return false;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
private __isInEditor(eventArg: Event): boolean {
|
|
1738
|
+
const path = (eventArg.composedPath?.() || []) as EventTarget[];
|
|
1739
|
+
for (const t of path) {
|
|
1740
|
+
const el = t as HTMLElement;
|
|
1741
|
+
const tag = el?.tagName;
|
|
1742
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || el?.isContentEditable) return true;
|
|
1743
|
+
if (tag && tag.startsWith('DEES-INPUT-')) return true;
|
|
1744
|
+
}
|
|
1745
|
+
return false;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
private __onTbodyClick = (eventArg: MouseEvent) => {
|
|
1749
|
+
if (this.__isInEditor(eventArg) || this.__isInActionsCol(eventArg)) return;
|
|
1750
|
+
const cell = this.__resolveCell(eventArg);
|
|
1751
|
+
if (!cell) return;
|
|
1752
|
+
const view: T[] = (this as any)._lastViewData ?? [];
|
|
1753
|
+
// Cell focus (when editable)
|
|
1754
|
+
if (cell.col.editable || cell.col.editor) {
|
|
1755
|
+
this.__focusedCell = {
|
|
1756
|
+
rowId: this.getRowId(cell.item),
|
|
1757
|
+
colKey: String(cell.col.key),
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
// Row selection (file-manager style)
|
|
1761
|
+
this.handleRowClick(eventArg, cell.item, cell.rowIdx, view);
|
|
1762
|
+
};
|
|
1763
|
+
|
|
1764
|
+
private __onTbodyDblclick = (eventArg: MouseEvent) => {
|
|
1765
|
+
if (this.__isInEditor(eventArg) || this.__isInActionsCol(eventArg)) return;
|
|
1766
|
+
const cell = this.__resolveCell(eventArg);
|
|
1767
|
+
if (!cell) return;
|
|
1768
|
+
const isEditable = !!(cell.col.editable || cell.col.editor);
|
|
1769
|
+
if (isEditable) {
|
|
1770
|
+
eventArg.stopPropagation();
|
|
1771
|
+
this.startEditing(cell.item, cell.col);
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
const dblAction = this.dataActions.find((a) => a.type?.includes('doubleClick'));
|
|
1775
|
+
if (dblAction) dblAction.actionFunc({ item: cell.item, table: this });
|
|
1776
|
+
};
|
|
1777
|
+
|
|
1778
|
+
private __onTbodyMousedown = (eventArg: MouseEvent) => {
|
|
1779
|
+
// Suppress browser's native shift-click text selection so range-select
|
|
1780
|
+
// doesn't highlight text mid-table.
|
|
1781
|
+
if (eventArg.shiftKey && this.selectionMode !== 'single') eventArg.preventDefault();
|
|
1782
|
+
};
|
|
1783
|
+
|
|
1784
|
+
private __onTbodyContextmenu = (eventArg: MouseEvent) => {
|
|
1785
|
+
if (this.__isInActionsCol(eventArg)) return;
|
|
1786
|
+
const row = this.__resolveRow(eventArg);
|
|
1787
|
+
if (!row) return;
|
|
1788
|
+
const item = row.item;
|
|
1789
|
+
// Match file-manager behavior: right-clicking a non-selected row makes
|
|
1790
|
+
// it the selection first.
|
|
1791
|
+
if (!this.isRowSelected(item)) {
|
|
1792
|
+
this.selectedDataRow = item;
|
|
1793
|
+
this.selectedIds.clear();
|
|
1794
|
+
this.selectedIds.add(this.getRowId(item));
|
|
1795
|
+
this.__selectionAnchorId = this.getRowId(item);
|
|
1796
|
+
this.emitSelectionChange();
|
|
1797
|
+
this.requestUpdate();
|
|
1798
|
+
}
|
|
1799
|
+
const userItems: plugins.tsclass.website.IMenuItem[] = this.getActionsForType('contextmenu').map(
|
|
1800
|
+
(action) => ({
|
|
1801
|
+
name: action.name,
|
|
1802
|
+
iconName: action.iconName as any,
|
|
1803
|
+
action: async () => {
|
|
1804
|
+
await action.actionFunc({ item, table: this });
|
|
1805
|
+
return null;
|
|
1806
|
+
},
|
|
1807
|
+
})
|
|
1808
|
+
);
|
|
1809
|
+
const defaultItems: plugins.tsclass.website.IMenuItem[] = [
|
|
1810
|
+
{
|
|
1811
|
+
name:
|
|
1812
|
+
this.selectedIds.size > 1
|
|
1813
|
+
? `Copy ${this.selectedIds.size} rows as JSON`
|
|
1814
|
+
: 'Copy row as JSON',
|
|
1815
|
+
iconName: 'lucide:Copy' as any,
|
|
1816
|
+
action: async () => {
|
|
1817
|
+
this.copySelectionAsJson(item);
|
|
1818
|
+
return null;
|
|
1819
|
+
},
|
|
1820
|
+
},
|
|
1821
|
+
];
|
|
1822
|
+
DeesContextmenu.openContextMenuWithOptions(eventArg, [...userItems, ...defaultItems]);
|
|
1823
|
+
};
|
|
1824
|
+
|
|
1825
|
+
private __onTbodyDragenter = (eventArg: DragEvent) => {
|
|
1826
|
+
eventArg.preventDefault();
|
|
1827
|
+
eventArg.stopPropagation();
|
|
1828
|
+
const row = this.__resolveRow(eventArg);
|
|
1829
|
+
if (!row) return;
|
|
1830
|
+
const tr = (eventArg.composedPath?.() || []).find(
|
|
1831
|
+
(t) => (t as HTMLElement)?.tagName === 'TR'
|
|
1832
|
+
) as HTMLElement | undefined;
|
|
1833
|
+
if (tr) setTimeout(() => tr.classList.add('hasAttachment'), 0);
|
|
1834
|
+
};
|
|
1835
|
+
|
|
1836
|
+
private __onTbodyDragleave = (eventArg: DragEvent) => {
|
|
1837
|
+
eventArg.preventDefault();
|
|
1838
|
+
eventArg.stopPropagation();
|
|
1839
|
+
const tr = (eventArg.composedPath?.() || []).find(
|
|
1840
|
+
(t) => (t as HTMLElement)?.tagName === 'TR'
|
|
1841
|
+
) as HTMLElement | undefined;
|
|
1842
|
+
if (tr) tr.classList.remove('hasAttachment');
|
|
1843
|
+
};
|
|
1844
|
+
|
|
1845
|
+
private __onTbodyDragover = (eventArg: DragEvent) => {
|
|
1846
|
+
eventArg.preventDefault();
|
|
1847
|
+
};
|
|
1848
|
+
|
|
1849
|
+
private __onTbodyDrop = async (eventArg: DragEvent) => {
|
|
1850
|
+
eventArg.preventDefault();
|
|
1851
|
+
const row = this.__resolveRow(eventArg);
|
|
1852
|
+
if (!row) return;
|
|
1853
|
+
const item = row.item;
|
|
1854
|
+
const newFiles: File[] = [];
|
|
1855
|
+
for (const file of Array.from(eventArg.dataTransfer!.files)) {
|
|
1856
|
+
this.files.push(file);
|
|
1857
|
+
newFiles.push(file);
|
|
1858
|
+
this.requestUpdate();
|
|
1859
|
+
}
|
|
1860
|
+
const existing: File[] | undefined = this.fileWeakMap.get(item as object);
|
|
1861
|
+
if (!existing) this.fileWeakMap.set(item as object, newFiles);
|
|
1862
|
+
else existing.push(...newFiles);
|
|
1863
|
+
};
|
|
1864
|
+
|
|
1505
1865
|
/**
|
|
1506
1866
|
* Handles row clicks with file-manager style selection semantics:
|
|
1507
1867
|
* - plain click: select only this row, set anchor
|