@haiyue/ui 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +166 -2
  3. package/dist/border-beam.d.ts +26 -0
  4. package/dist/border-beam.js +172 -0
  5. package/dist/button.d.ts +10 -0
  6. package/dist/button.js +85 -0
  7. package/dist/checkbox.d.ts +2 -2
  8. package/dist/checkbox.js +11 -11
  9. package/dist/context-menu.d.ts +6 -6
  10. package/dist/context-menu.js +11 -9
  11. package/dist/dialog.d.ts +5 -5
  12. package/dist/dialog.js +18 -18
  13. package/dist/drawer.d.ts +49 -0
  14. package/dist/drawer.js +377 -0
  15. package/dist/dropdown.d.ts +9 -9
  16. package/dist/dropdown.js +9 -9
  17. package/dist/history-controls.d.ts +40 -0
  18. package/dist/history-controls.js +123 -0
  19. package/dist/index.d.ts +34 -31
  20. package/dist/index.js +32 -85
  21. package/dist/input.d.ts +5 -5
  22. package/dist/input.js +13 -13
  23. package/dist/menu-shared.d.ts +4 -4
  24. package/dist/menu-shared.js +4 -4
  25. package/dist/notification.d.ts +55 -0
  26. package/dist/notification.js +357 -0
  27. package/dist/radio.d.ts +2 -2
  28. package/dist/radio.js +12 -12
  29. package/dist/range.d.ts +74 -0
  30. package/dist/range.js +393 -0
  31. package/dist/select.d.ts +6 -6
  32. package/dist/select.js +20 -20
  33. package/dist/split.d.ts +6 -6
  34. package/dist/split.js +14 -14
  35. package/dist/tabs.d.ts +6 -6
  36. package/dist/tabs.js +14 -14
  37. package/dist/tooltip.d.ts +4 -4
  38. package/dist/tooltip.js +9 -9
  39. package/dist/tree-model.d.ts +15 -7
  40. package/dist/tree-model.js +93 -80
  41. package/dist/tree-node.d.ts +3 -3
  42. package/dist/tree-node.js +3 -3
  43. package/dist/tree-types.d.ts +14 -14
  44. package/dist/tree.d.ts +9 -7
  45. package/dist/tree.js +68 -66
  46. package/dist/virtual-list.d.ts +61 -0
  47. package/dist/virtual-list.js +322 -0
  48. package/package.json +87 -11
  49. package/themes/haiyue-dark.css +46 -0
  50. package/themes/haiyue-light.css +46 -0
  51. package/src/checkbox.ts +0 -215
  52. package/src/context-menu.ts +0 -248
  53. package/src/dialog.ts +0 -195
  54. package/src/dropdown.ts +0 -249
  55. package/src/index.ts +0 -192
  56. package/src/input.ts +0 -150
  57. package/src/menu-shared.ts +0 -65
  58. package/src/radio.ts +0 -216
  59. package/src/select.ts +0 -271
  60. package/src/split.ts +0 -306
  61. package/src/tabs.ts +0 -182
  62. package/src/tooltip.ts +0 -321
  63. package/src/tree-model.ts +0 -305
  64. package/src/tree-node.ts +0 -67
  65. package/src/tree-types.ts +0 -47
  66. package/src/tree.ts +0 -1002
package/src/tree.ts DELETED
@@ -1,1002 +0,0 @@
1
- import { GETreeModel } from './tree-model.js';
2
- import { GETreeNode } from './tree-node.js';
3
- import type {
4
- FlatTreeNode,
5
- GETreeDataChangeDetail,
6
- GETreeDropPosition,
7
- GETreeNodeContextMenuDetail,
8
- GETreeNodeData,
9
- GETreeSelectionChangeDetail,
10
- } from './tree-types.js';
11
-
12
- export { GETreeNode } from './tree-node.js';
13
- export type {
14
- GETreeDataChangeDetail,
15
- GETreeDropPosition,
16
- GETreeNodeContextMenuDetail,
17
- GETreeNodeData,
18
- GETreeSelectionChangeDetail,
19
- } from './tree-types.js';
20
-
21
- interface RenderedTreeRow {
22
- row: HTMLElement;
23
- toggle: HTMLButtonElement;
24
- content: HTMLElement;
25
- renderer: HTMLElement & {
26
- node?: GETreeNodeData;
27
- depth?: number;
28
- selected?: boolean;
29
- expanded?: boolean;
30
- hasChildren?: boolean;
31
- };
32
- rendererTag: string;
33
- }
34
-
35
- const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
36
- const TREE_TOGGLE_ICON_PATHS = [
37
- 'M318.57 223.95l322.99 322.99c21.87 21.87 57.33 21.87 79.2 0 21.87-21.87 21.87-57.33 0-79.2l-323-322.99c-21.87-21.87-57.33-21.87-79.2 0-21.86 21.87-21.86 57.33 0.01 79.2z',
38
- 'M729.75 555.95L406.76 878.93c-21.87 21.87-57.33 21.87-79.2 0-21.87-21.87-21.87-57.33 0-79.2l322.99-322.99c21.87-21.87 57.33-21.87 79.2 0 21.87 21.88 21.87 57.34 0 79.21z',
39
- ];
40
- const HIERARCHY_MEASURE_PREFIX = 'editor.hierarchy.';
41
-
42
- function measureHierarchyTreeStage<T>(stage: 'index-update' | 'tree-rebuild', operation: () => T): T {
43
- const startedAt = performance.now();
44
- try {
45
- return operation();
46
- } finally {
47
- const name = `${HIERARCHY_MEASURE_PREFIX}${stage}`;
48
- performance.clearMeasures(name);
49
- performance.measure(name, { start: startedAt, end: performance.now() });
50
- }
51
- }
52
-
53
- function createTreeToggleIconSvg(): SVGSVGElement {
54
- const svg = document.createElementNS(SVG_NAMESPACE, 'svg');
55
- svg.setAttribute('class', 'toggle-icon');
56
- svg.setAttribute('viewBox', '0 0 1024 1024');
57
- svg.setAttribute('aria-hidden', 'true');
58
- svg.setAttribute('focusable', 'false');
59
- for (const pathData of TREE_TOGGLE_ICON_PATHS) {
60
- const path = document.createElementNS(SVG_NAMESPACE, 'path');
61
- path.setAttribute('d', pathData);
62
- path.setAttribute('fill', 'currentColor');
63
- svg.append(path);
64
- }
65
- return svg;
66
- }
67
-
68
- export class GETree extends HTMLElement {
69
- private static readonly VIRTUALIZATION_THRESHOLD = 160;
70
- private static readonly ROW_HEIGHT = 28;
71
- private static readonly OVERSCAN_ROWS = 8;
72
- private readonly _root: ShadowRoot;
73
- private readonly _style = document.createElement('style');
74
- private readonly _list = document.createElement('div');
75
- private readonly _rowCache = new Map<string, RenderedTreeRow>();
76
- private _clipboard: GETreeNodeData[] = [];
77
- private _data: GETreeNodeData[] = [];
78
- private _selectedId: string | null = null;
79
- private _selectedIds = new Set<string>();
80
- private _selectionAnchorId: string | null = null;
81
- private _activeId: string | null = null;
82
- private _expandedIds = new Set<string>();
83
- private readonly _model = new GETreeModel(this._expandedIds);
84
- private _dragNodeId: string | null = null;
85
- private _syncingSelectionAttributes = false;
86
- private _resizeObserver: ResizeObserver | null = null;
87
-
88
- static get observedAttributes(): string[] {
89
- return ['data', 'selected-id', 'selected-ids', 'allow-drag'];
90
- }
91
-
92
- constructor() {
93
- super();
94
- this._root = this.attachShadow({ mode: 'open' });
95
- this._style.textContent = `
96
- :host {
97
- box-sizing: border-box;
98
- display: flex;
99
- flex-direction: column;
100
- align-items: stretch;
101
- justify-content: flex-start;
102
- width: 100%;
103
- min-height: 0;
104
- color: var(--ge-tree-text-color, var(--ge-text-color, #c8d3e4));
105
- background: var(--ge-panel-bg-color, #171d28);
106
- font: 12px system-ui, sans-serif;
107
- text-align: left;
108
- user-select: none;
109
- overflow: auto;
110
- }
111
- .list {
112
- box-sizing: border-box;
113
- width: 100%;
114
- flex: 0 0 auto;
115
- padding: 4px;
116
- }
117
- .row {
118
- height: 28px;
119
- display: flex;
120
- align-items: center;
121
- gap: 4px;
122
- border-radius: 4px;
123
- color: var(--ge-tree-text-color, var(--ge-text-color, #c8d3e4));
124
- cursor: default;
125
- }
126
- .virtual-spacer {
127
- width: 1px;
128
- pointer-events: none;
129
- }
130
- .row:hover {
131
- background: var(--ge-hover-bg-color, #222b3a);
132
- }
133
- .row.selected {
134
- background: var(--ge-selected-bg-color, var(--ge-accent-strong-color, #255a91));
135
- color: var(--ge-selected-text-color, #fff);
136
- }
137
- .row.active:not(.selected) {
138
- background: var(--ge-tree-active-bg-color, var(--ge-hover-bg-color, #222b3a));
139
- color: var(--ge-tree-active-text-color, var(--ge-tree-text-color, #dce8fb));
140
- }
141
- .row.active {
142
- outline: var(--ge-tree-active-ring-width, 1px) solid var(--ge-tree-active-ring-color, var(--ge-focus-border-color, #3d6fa8));
143
- outline-offset: -1px;
144
- }
145
- .toggle {
146
- width: 18px;
147
- height: 18px;
148
- flex: 0 0 18px;
149
- border: 0;
150
- padding: 0;
151
- background: transparent;
152
- color: var(--ge-tree-icon-color, var(--ge-secondary-text-color, #8fa7c8));
153
- cursor: pointer;
154
- display: inline-grid;
155
- place-items: center;
156
- }
157
- .toggle.empty {
158
- cursor: default;
159
- visibility: hidden;
160
- }
161
- .toggle-icon {
162
- width: 12px;
163
- height: 12px;
164
- color: var(--ge-tree-icon-color, var(--ge-secondary-text-color, #8fa7c8));
165
- transition: transform 0.16s ease, color 0.16s ease;
166
- }
167
- .toggle.expanded .toggle-icon {
168
- transform: rotate(90deg);
169
- color: var(--ge-tree-icon-active-color, #b9d4ff);
170
- }
171
- .row:hover .toggle-icon {
172
- color: var(--ge-tree-icon-active-color, #b9d4ff);
173
- }
174
- .content {
175
- min-width: 0;
176
- flex: 1 1 auto;
177
- }
178
- .row.drop-before {
179
- box-shadow: inset 0 2px 0 var(--ge-drop-indicator-color, #62a8ff);
180
- }
181
- .row.drop-after {
182
- box-shadow: inset 0 -2px 0 var(--ge-drop-indicator-color, #62a8ff);
183
- }
184
- .row.drop-inside {
185
- outline: 1px solid var(--ge-drop-indicator-color, #62a8ff);
186
- background: var(--ge-drop-target-bg-color, #223650);
187
- }
188
- `;
189
- this._list.className = 'list';
190
- this._root.append(this._style, this._list);
191
- }
192
-
193
- connectedCallback(): void {
194
- if (!this.hasAttribute('tabindex')) this.tabIndex = 0;
195
- if (!this.hasAttribute('role')) this.setAttribute('role', 'tree');
196
- this._syncFromAttributes();
197
- this.addEventListener('keydown', this._onKeyDown);
198
- this._list.addEventListener('click', this._onListClick);
199
- this._list.addEventListener('contextmenu', this._onListContextMenu);
200
- this._list.addEventListener('dragstart', this._onListDragStart);
201
- this._list.addEventListener('dragover', this._onListDragOver);
202
- this._list.addEventListener('dragleave', this._onListDragLeave);
203
- this._list.addEventListener('drop', this._onListDrop);
204
- this._list.addEventListener('dragend', this._onListDragEnd);
205
- this.addEventListener('scroll', this._onScroll, { passive: true });
206
- if (typeof ResizeObserver !== 'undefined') {
207
- this._resizeObserver = new ResizeObserver(() => this._render());
208
- this._resizeObserver.observe(this);
209
- }
210
- this._render();
211
- }
212
-
213
- disconnectedCallback(): void {
214
- this.removeEventListener('keydown', this._onKeyDown);
215
- this._list.removeEventListener('click', this._onListClick);
216
- this._list.removeEventListener('contextmenu', this._onListContextMenu);
217
- this._list.removeEventListener('dragstart', this._onListDragStart);
218
- this._list.removeEventListener('dragover', this._onListDragOver);
219
- this._list.removeEventListener('dragleave', this._onListDragLeave);
220
- this._list.removeEventListener('drop', this._onListDrop);
221
- this._list.removeEventListener('dragend', this._onListDragEnd);
222
- this.removeEventListener('scroll', this._onScroll);
223
- this._resizeObserver?.disconnect();
224
- this._resizeObserver = null;
225
- }
226
-
227
- attributeChangedCallback(name: string): void {
228
- if (name === 'data') this._readDataAttribute();
229
- if (this._syncingSelectionAttributes) return;
230
- if (!this._syncingSelectionAttributes) {
231
- if (name === 'selected-id') this._setSelectionFromSelectedIdAttribute();
232
- if (name === 'selected-ids') this._setSelectionFromSelectedIdsAttribute();
233
- }
234
- this._render();
235
- }
236
-
237
- get data(): GETreeNodeData[] {
238
- return this._data;
239
- }
240
-
241
- set data(value: GETreeNodeData[]) {
242
- this._data = Array.isArray(value) ? value : [];
243
- this._seedExpanded(this._data);
244
- this._model.setData(this._data);
245
- this._render();
246
- }
247
-
248
- get selectedId(): string | null {
249
- return this._selectedId;
250
- }
251
-
252
- set selectedId(value: string | null) {
253
- this._setSelection(value ? [value] : [], value, false);
254
- }
255
-
256
- get selectedIds(): string[] {
257
- return [...this._selectedIds];
258
- }
259
-
260
- set selectedIds(value: string[]) {
261
- const ids = Array.isArray(value) ? value : [];
262
- this._setSelection(ids, ids[ids.length - 1] ?? null, false);
263
- }
264
-
265
- get allowDrag(): boolean {
266
- return this.hasAttribute('allow-drag');
267
- }
268
-
269
- set allowDrag(value: boolean) {
270
- if (value) this.setAttribute('allow-drag', '');
271
- else this.removeAttribute('allow-drag');
272
- }
273
-
274
- updateData(value: GETreeNodeData[]): void {
275
- this.data = value;
276
- }
277
-
278
- expand(id: string): void {
279
- this._expandedIds.add(id);
280
- this._model.invalidateVisibility();
281
- this._render();
282
- }
283
-
284
- collapse(id: string): void {
285
- this._expandedIds.delete(id);
286
- this._model.invalidateVisibility();
287
- this._render();
288
- }
289
-
290
- toggle(id: string): void {
291
- if (this._expandedIds.has(id)) this._expandedIds.delete(id);
292
- else this._expandedIds.add(id);
293
- this._model.invalidateVisibility();
294
- this._render();
295
- }
296
-
297
- reveal(id: string): boolean {
298
- const indexed = this._model.getIndexedNode(id);
299
- if (!indexed) return false;
300
- for (const ancestorId of indexed.ancestorIds) this._expandedIds.add(ancestorId);
301
- this._model.invalidateVisibility();
302
- this._render();
303
- this._setActiveId(id, true);
304
- return this._model.visibleIndex.has(id);
305
- }
306
-
307
- copySelection(): boolean {
308
- return this._copySelection();
309
- }
310
-
311
- pasteClipboard(): boolean {
312
- return this._pasteClipboard();
313
- }
314
-
315
- deleteSelection(): boolean {
316
- return this._deleteSelection();
317
- }
318
-
319
- cutSelection(): boolean {
320
- if (!this._copySelection()) return false;
321
- return this._deleteSelection();
322
- }
323
-
324
- private _syncFromAttributes(): void {
325
- this._readDataAttribute();
326
- if (this.hasAttribute('selected-ids')) this._setSelectionFromSelectedIdsAttribute();
327
- else this._setSelectionFromSelectedIdAttribute();
328
- }
329
-
330
- private _readDataAttribute(): void {
331
- const raw = this.getAttribute('data');
332
- if (!raw) {
333
- this._data = [];
334
- this._model.setData(this._data);
335
- return;
336
- }
337
- try {
338
- const parsed = JSON.parse(raw);
339
- this._data = Array.isArray(parsed) ? parsed : [];
340
- this._seedExpanded(this._data);
341
- this._model.setData(this._data);
342
- } catch (error) {
343
- console.warn('Invalid ge-tree data attribute.', error);
344
- }
345
- }
346
-
347
- private _seedExpanded(nodes: GETreeNodeData[]): void {
348
- const visit = (items: GETreeNodeData[]) => {
349
- for (const node of items) {
350
- if (node.expanded) this._expandedIds.add(node.id);
351
- if (node.children) visit(node.children);
352
- }
353
- };
354
- visit(nodes);
355
- }
356
-
357
- private _render(): void {
358
- this._ensureActiveVisible();
359
- const visible = this._getVisibleFlatNodes();
360
- const window = this._getRenderWindow(visible.length);
361
- const visibleIds = new Set<string>();
362
- const fragment = document.createDocumentFragment();
363
- if (window.start > 0) fragment.append(this._createVirtualSpacer(window.start * GETree.ROW_HEIGHT));
364
- for (let index = window.start; index < window.end; index++) {
365
- const item = visible[index];
366
- if (!item) continue;
367
- visibleIds.add(item.node.id);
368
- const rendered = this._getOrCreateRow(item.node.id);
369
- this._syncRow(rendered, item);
370
- fragment.append(rendered.row);
371
- }
372
- if (window.end < visible.length) {
373
- fragment.append(this._createVirtualSpacer((visible.length - window.end) * GETree.ROW_HEIGHT));
374
- }
375
-
376
- for (const [id, rendered] of this._rowCache) {
377
- if (visibleIds.has(id)) continue;
378
- rendered.row.remove();
379
- this._rowCache.delete(id);
380
- }
381
- this._list.replaceChildren(fragment);
382
- }
383
-
384
- private _getRenderWindow(itemCount: number): { start: number; end: number } {
385
- const viewportHeight = this.clientHeight;
386
- if (itemCount < GETree.VIRTUALIZATION_THRESHOLD || viewportHeight <= 0) return { start: 0, end: itemCount };
387
- const first = Math.floor(this.scrollTop / GETree.ROW_HEIGHT);
388
- const last = Math.ceil((this.scrollTop + viewportHeight) / GETree.ROW_HEIGHT);
389
- return {
390
- start: Math.max(0, first - GETree.OVERSCAN_ROWS),
391
- end: Math.min(itemCount, last + GETree.OVERSCAN_ROWS),
392
- };
393
- }
394
-
395
- private _createVirtualSpacer(height: number): HTMLDivElement {
396
- const spacer = document.createElement('div');
397
- spacer.className = 'virtual-spacer';
398
- spacer.style.height = `${height}px`;
399
- spacer.setAttribute('aria-hidden', 'true');
400
- return spacer;
401
- }
402
-
403
- private _getVisibleFlatNodes(): readonly FlatTreeNode[] {
404
- return this._model.getVisibleNodes();
405
- }
406
-
407
- private _getVisibleIds(): readonly string[] {
408
- return this._model.getVisibleIds();
409
- }
410
-
411
- private _getOrCreateRow(id: string): RenderedTreeRow {
412
- const cached = this._rowCache.get(id);
413
- if (cached) return cached;
414
-
415
- const row = document.createElement('div');
416
- const toggle = document.createElement('button');
417
- const content = document.createElement('div');
418
- const renderer = document.createElement('ge-tree-node') as RenderedTreeRow['renderer'];
419
-
420
- row.className = 'row';
421
- row.dataset.id = id;
422
- toggle.type = 'button';
423
- toggle.append(createTreeToggleIconSvg());
424
- content.className = 'content';
425
- content.append(renderer);
426
- row.append(toggle, content);
427
-
428
- const rendered = { row, toggle, content, renderer, rendererTag: 'ge-tree-node' };
429
- this._rowCache.set(id, rendered);
430
- return rendered;
431
- }
432
-
433
- private _syncRow(rendered: RenderedTreeRow, item: FlatTreeNode): void {
434
- const { node, depth } = item;
435
- const hasChildren = Boolean(node.children?.length);
436
- const expanded = this._expandedIds.has(node.id);
437
- const selected = this._selectedIds.has(node.id);
438
- const active = this._activeId === node.id;
439
- const rendererTag = this._getRendererTag(node);
440
- const { row, toggle, content } = rendered;
441
-
442
- row.className = `row${selected ? ' selected' : ''}${active ? ' active' : ''}`;
443
- row.dataset.id = node.id;
444
- row.style.paddingLeft = `${depth * 16 + 4}px`;
445
- row.draggable = this.allowDrag;
446
-
447
- toggle.className = `${hasChildren ? 'toggle' : 'toggle empty'}${expanded ? ' expanded' : ''}`;
448
- toggle.disabled = !hasChildren;
449
- toggle.setAttribute('aria-hidden', hasChildren ? 'false' : 'true');
450
- toggle.tabIndex = hasChildren ? 0 : -1;
451
-
452
- if (rendered.rendererTag !== rendererTag) {
453
- rendered.renderer = document.createElement(rendererTag) as RenderedTreeRow['renderer'];
454
- rendered.rendererTag = rendererTag;
455
- content.replaceChildren(rendered.renderer);
456
- }
457
- this._syncRenderer(rendered.renderer, node, depth, expanded, hasChildren, selected);
458
- }
459
-
460
- private _getRendererTag(node: GETreeNodeData): string {
461
- return typeof node.renderer === 'string' && node.renderer ? node.renderer : 'ge-tree-node';
462
- }
463
-
464
- private _syncRenderer(
465
- el: RenderedTreeRow['renderer'],
466
- node: GETreeNodeData,
467
- depth: number,
468
- expanded: boolean,
469
- hasChildren: boolean,
470
- selected: boolean,
471
- ): void {
472
- el.node = node;
473
- el.depth = depth;
474
- el.selected = selected;
475
- el.expanded = expanded;
476
- el.hasChildren = hasChildren;
477
- el.id = this._getTreeItemElementId(node.id);
478
- if (!el.hasAttribute('role')) el.setAttribute('role', 'treeitem');
479
- el.setAttribute('aria-level', String(depth + 1));
480
- if (hasChildren) el.setAttribute('aria-expanded', expanded ? 'true' : 'false');
481
- else el.removeAttribute('aria-expanded');
482
- el.setAttribute('label', String(node.label ?? node.id));
483
- if (node.icon) el.setAttribute('icon', String(node.icon));
484
- else el.removeAttribute('icon');
485
- if (selected) {
486
- el.setAttribute('selected', '');
487
- el.setAttribute('aria-selected', 'true');
488
- } else {
489
- el.removeAttribute('selected');
490
- el.setAttribute('aria-selected', 'false');
491
- }
492
- }
493
-
494
- private _syncRenderedSelection(): void {
495
- for (const [id, rendered] of this._rowCache) {
496
- const selected = this._selectedIds.has(id);
497
- rendered.row.classList.toggle('selected', selected);
498
- rendered.renderer.selected = selected;
499
- if (selected) {
500
- rendered.renderer.setAttribute('selected', '');
501
- rendered.renderer.setAttribute('aria-selected', 'true');
502
- } else {
503
- rendered.renderer.removeAttribute('selected');
504
- rendered.renderer.setAttribute('aria-selected', 'false');
505
- }
506
- }
507
- }
508
-
509
- private _syncRenderedActive(): void {
510
- for (const [id, rendered] of this._rowCache) {
511
- rendered.row.classList.toggle('active', this._activeId === id);
512
- }
513
- this._syncActiveDescendant();
514
- }
515
-
516
- private _select(id: string, event: MouseEvent): void {
517
- const visibleIds = this._getVisibleIds();
518
- let ids: string[];
519
-
520
- if (event.shiftKey && this._selectionAnchorId) {
521
- ids = this._getRangeSelection(visibleIds, this._selectionAnchorId, id);
522
- } else if (event.metaKey || event.ctrlKey) {
523
- const next = new Set(this._selectedIds);
524
- if (next.has(id)) next.delete(id);
525
- else next.add(id);
526
- ids = [...next];
527
- this._selectionAnchorId = id;
528
- } else {
529
- ids = [id];
530
- this._selectionAnchorId = id;
531
- }
532
-
533
- this._setSelection(ids, id, true);
534
- }
535
-
536
- private _setSelection(ids: string[], selectedId: string | null, emit: boolean): void {
537
- const uniqueIds = [...new Set(ids)].filter(id => this._model.index.has(id));
538
- this._selectedIds = new Set(uniqueIds);
539
- this._selectedId = selectedId && this._selectedIds.has(selectedId)
540
- ? selectedId
541
- : uniqueIds[uniqueIds.length - 1] ?? null;
542
- if (this._selectedId && !this._selectionAnchorId) this._selectionAnchorId = this._selectedId;
543
- this._setActiveId(this._selectedId, false);
544
- this._syncSelectionAttributes();
545
- if (this._model.isVisibilityDirty) this._render();
546
- else this._syncRenderedSelection();
547
-
548
- if (!emit) return;
549
- const node = this._selectedId ? this._model.getIndexedNode(this._selectedId)?.node ?? null : null;
550
- const nodes = uniqueIds
551
- .map(id => this._model.getIndexedNode(id)?.node ?? null)
552
- .filter((item): item is GETreeNodeData => item !== null);
553
- this.dispatchEvent(new CustomEvent<GETreeSelectionChangeDetail>('selection-change', {
554
- detail: { selectedId: this._selectedId, node, selectedIds: uniqueIds, nodes },
555
- bubbles: true,
556
- composed: true,
557
- }));
558
- }
559
-
560
- private _syncSelectionAttributes(): void {
561
- this._syncingSelectionAttributes = true;
562
- if (this._selectedId === null) this.removeAttribute('selected-id');
563
- else this.setAttribute('selected-id', this._selectedId);
564
-
565
- if (this._selectedIds.size === 0) this.removeAttribute('selected-ids');
566
- else this.setAttribute('selected-ids', JSON.stringify([...this._selectedIds]));
567
- this._syncingSelectionAttributes = false;
568
- }
569
-
570
- private _setSelectionFromSelectedIdAttribute(): void {
571
- const id = this.getAttribute('selected-id');
572
- this._selectedIds = id ? new Set([id]) : new Set();
573
- this._selectedId = id;
574
- this._selectionAnchorId = id;
575
- this._activeId = id;
576
- }
577
-
578
- private _setSelectionFromSelectedIdsAttribute(): void {
579
- const raw = this.getAttribute('selected-ids');
580
- if (!raw) {
581
- this._setSelectionFromSelectedIdAttribute();
582
- return;
583
- }
584
-
585
- try {
586
- const parsed = JSON.parse(raw);
587
- const ids = Array.isArray(parsed) ? parsed.map(String) : [];
588
- this._selectedIds = new Set(ids);
589
- this._selectedId = ids[ids.length - 1] ?? null;
590
- this._selectionAnchorId = this._selectedId;
591
- this._activeId = this._selectedId;
592
- } catch (error) {
593
- console.warn('Invalid ge-tree selected-ids attribute.', error);
594
- this._setSelectionFromSelectedIdAttribute();
595
- }
596
- }
597
-
598
- private _getRangeSelection(visibleIds: readonly string[], fromId: string, toId: string): string[] {
599
- this._getVisibleFlatNodes();
600
- const fromIndex = this._model.visibleIndex.get(fromId) ?? -1;
601
- const toIndex = this._model.visibleIndex.get(toId) ?? -1;
602
- if (fromIndex < 0 || toIndex < 0) return [toId];
603
- const start = Math.min(fromIndex, toIndex);
604
- const end = Math.max(fromIndex, toIndex);
605
- return visibleIds.slice(start, end + 1);
606
- }
607
-
608
- private _getEventRow(target: EventTarget | null): HTMLElement | null {
609
- if (!(target instanceof Element)) return null;
610
- const row = target.closest<HTMLElement>('.row');
611
- return row && this._list.contains(row) ? row : null;
612
- }
613
-
614
- private _getRowNode(row: HTMLElement): GETreeNodeData | null {
615
- const id = row.dataset.id;
616
- return id ? this._model.getIndexedNode(id)?.node ?? null : null;
617
- }
618
-
619
- private _onListClick = (event: MouseEvent): void => {
620
- const row = this._getEventRow(event.target);
621
- if (!row) return;
622
- const node = this._getRowNode(row);
623
- if (!node) return;
624
-
625
- const toggle = event.target instanceof Element ? event.target.closest('.toggle') : null;
626
- if (toggle && row.contains(toggle)) {
627
- event.stopPropagation();
628
- if (node.children?.length) this.toggle(node.id);
629
- return;
630
- }
631
-
632
- this.focus();
633
- this._setActiveId(node.id, false);
634
- this._select(node.id, event);
635
- };
636
-
637
- private _onListContextMenu = (event: MouseEvent): void => {
638
- const row = this._getEventRow(event.target);
639
- if (!row) return;
640
- const node = this._getRowNode(row);
641
- if (!node) return;
642
-
643
- event.preventDefault();
644
- event.stopPropagation();
645
- this.focus();
646
- this._setActiveId(node.id, false);
647
- if (!this._selectedIds.has(node.id)) this._setSelection([node.id], node.id, true);
648
- this.dispatchEvent(new CustomEvent<GETreeNodeContextMenuDetail>('node-context-menu', {
649
- detail: {
650
- id: node.id,
651
- node,
652
- selectedIds: this.selectedIds,
653
- clientX: event.clientX,
654
- clientY: event.clientY,
655
- },
656
- bubbles: true,
657
- composed: true,
658
- }));
659
- };
660
-
661
- private _onListDragStart = (event: DragEvent): void => {
662
- const row = this._getEventRow(event.target);
663
- if (!row || !this.allowDrag || !row.dataset.id) return;
664
- this._onDragStart(event, row.dataset.id);
665
- };
666
-
667
- private _onListDragOver = (event: DragEvent): void => {
668
- const row = this._getEventRow(event.target);
669
- if (!row || !this.allowDrag) return;
670
- this._onDragOver(event, row);
671
- };
672
-
673
- private _onListDragLeave = (event: DragEvent): void => {
674
- const row = this._getEventRow(event.target);
675
- if (row) this._clearDropClass(row);
676
- };
677
-
678
- private _onListDrop = (event: DragEvent): void => {
679
- const row = this._getEventRow(event.target);
680
- if (!row || !this.allowDrag || !row.dataset.id) return;
681
- this._onDrop(event, row, row.dataset.id);
682
- };
683
-
684
- private _onListDragEnd = (): void => {
685
- this._clearAllDropClasses();
686
- };
687
-
688
- private _onDragStart(event: DragEvent, id: string): void {
689
- this._dragNodeId = id;
690
- event.dataTransfer?.setData('text/plain', id);
691
- if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
692
- }
693
-
694
- private _onDragOver(event: DragEvent, row: HTMLElement): void {
695
- event.preventDefault();
696
- if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
697
- this._clearAllDropClasses();
698
- row.classList.add(this._getDropClass(event, row));
699
- }
700
-
701
- private _onDrop(event: DragEvent, row: HTMLElement, targetId: string): void {
702
- performance.clearMarks(`${HIERARCHY_MEASURE_PREFIX}input-start`);
703
- performance.mark(`${HIERARCHY_MEASURE_PREFIX}input-start`);
704
- event.preventDefault();
705
- const sourceId = this._dragNodeId ?? event.dataTransfer?.getData('text/plain') ?? null;
706
- this._clearAllDropClasses();
707
- this._dragNodeId = null;
708
- if (!sourceId || sourceId === targetId) return;
709
- const position = this._getDropPosition(event, row);
710
- if (measureHierarchyTreeStage('index-update', () => this._model.moveNode(sourceId, targetId, position))) {
711
- measureHierarchyTreeStage('tree-rebuild', () => this._render());
712
- this.dispatchEvent(new CustomEvent<GETreeDataChangeDetail>('data-change', {
713
- detail: { data: this._data, action: 'drop', sourceId, targetId, dropPosition: position },
714
- bubbles: true,
715
- composed: true,
716
- }));
717
- }
718
- }
719
-
720
- private _onKeyDown = (event: KeyboardEvent): void => {
721
- if (event.defaultPrevented || this._isEditableTarget(event.target)) return;
722
- const key = event.key.toLowerCase();
723
- const command = event.metaKey || event.ctrlKey;
724
-
725
- if (command && key === 'c') {
726
- if (this._copySelection()) event.preventDefault();
727
- return;
728
- }
729
-
730
- if (command && key === 'v') {
731
- if (this._pasteClipboard()) event.preventDefault();
732
- return;
733
- }
734
-
735
- if (event.key === 'Delete' || event.key === 'Backspace') {
736
- if (this._deleteSelection()) event.preventDefault();
737
- return;
738
- }
739
-
740
- if (this._handleNavigationKey(event)) event.preventDefault();
741
- };
742
-
743
- private _handleNavigationKey(event: KeyboardEvent): boolean {
744
- if (event.altKey || event.metaKey || event.ctrlKey) return false;
745
- const visibleIds = this._getVisibleIds();
746
- if (visibleIds.length === 0) return false;
747
-
748
- const activeId = this._getActiveVisibleId() ?? visibleIds[0];
749
- if (activeId === undefined) return false;
750
- const activeIndex = this._model.visibleIndex.get(activeId) ?? 0;
751
-
752
- if (event.key === 'ArrowDown') {
753
- this._setActiveId(visibleIds[Math.min(activeIndex + 1, visibleIds.length - 1)] ?? activeId);
754
- return true;
755
- }
756
-
757
- if (event.key === 'ArrowUp') {
758
- this._setActiveId(visibleIds[Math.max(activeIndex - 1, 0)] ?? activeId);
759
- return true;
760
- }
761
-
762
- if (event.key === 'Home') {
763
- this._setActiveId(visibleIds[0] ?? activeId);
764
- return true;
765
- }
766
-
767
- if (event.key === 'End') {
768
- this._setActiveId(visibleIds[visibleIds.length - 1] ?? activeId);
769
- return true;
770
- }
771
-
772
- if (event.key === 'ArrowRight') {
773
- return this._moveActiveRight(activeId);
774
- }
775
-
776
- if (event.key === 'ArrowLeft') {
777
- return this._moveActiveLeft(activeId);
778
- }
779
-
780
- if (event.key === 'Enter' || event.key === ' ') {
781
- this._setSelection([activeId], activeId, true);
782
- return true;
783
- }
784
-
785
- return false;
786
- }
787
-
788
- private _moveActiveRight(activeId: string): boolean {
789
- const indexed = this._model.getIndexedNode(activeId);
790
- if (!indexed?.node.children?.length) return false;
791
- if (!this._expandedIds.has(activeId)) {
792
- this._expandedIds.add(activeId);
793
- this._model.invalidateVisibility();
794
- this._render();
795
- this._scrollActiveIntoView();
796
- return true;
797
- }
798
-
799
- const visibleIds = this._getVisibleIds();
800
- const activeIndex = this._model.visibleIndex.get(activeId) ?? -1;
801
- const nextId = visibleIds[activeIndex + 1];
802
- if (nextId) this._setActiveId(nextId);
803
- return Boolean(nextId);
804
- }
805
-
806
- private _moveActiveLeft(activeId: string): boolean {
807
- const indexed = this._model.getIndexedNode(activeId);
808
- if (!indexed) return false;
809
- if (indexed.node.children?.length && this._expandedIds.has(activeId)) {
810
- this._expandedIds.delete(activeId);
811
- this._model.invalidateVisibility();
812
- this._render();
813
- this._scrollActiveIntoView();
814
- return true;
815
- }
816
-
817
- const parentId = indexed.parent?.id;
818
- if (parentId) this._setActiveId(parentId);
819
- return Boolean(parentId);
820
- }
821
-
822
- private _getActiveVisibleId(): string | null {
823
- this._getVisibleFlatNodes();
824
- if (this._activeId && this._model.visibleIndex.has(this._activeId)) return this._activeId;
825
- if (this._selectedId && this._model.visibleIndex.has(this._selectedId)) return this._selectedId;
826
- return null;
827
- }
828
-
829
- private _ensureActiveVisible(): void {
830
- const visibleIds = this._getVisibleIds();
831
- if (this._activeId && this._model.visibleIndex.has(this._activeId)) {
832
- this._syncActiveDescendant();
833
- return;
834
- }
835
- this._activeId = this._selectedId && this._model.visibleIndex.has(this._selectedId)
836
- ? this._selectedId
837
- : visibleIds[0] ?? null;
838
- this._syncActiveDescendant();
839
- }
840
-
841
- private _setActiveId(id: string | null, scroll = true): void {
842
- this._getVisibleFlatNodes();
843
- const nextId = id && this._model.visibleIndex.has(id) ? id : this._getActiveVisibleId();
844
- if (this._activeId === nextId) {
845
- if (scroll) this._scrollActiveIntoView();
846
- return;
847
- }
848
- this._activeId = nextId;
849
- this._syncRenderedActive();
850
- if (scroll) this._scrollActiveIntoView();
851
- }
852
-
853
- private _syncActiveDescendant(): void {
854
- if (this._activeId) this.setAttribute('aria-activedescendant', this._getTreeItemElementId(this._activeId));
855
- else this.removeAttribute('aria-activedescendant');
856
- }
857
-
858
- private _getTreeItemElementId(id: string): string {
859
- return `ge-tree-item-${encodeURIComponent(id)}`;
860
- }
861
-
862
- private _scrollActiveIntoView(): void {
863
- if (!this._activeId) return;
864
- let rendered = this._rowCache.get(this._activeId);
865
- if (!rendered && this.clientHeight > 0) {
866
- this._getVisibleFlatNodes();
867
- const index = this._model.visibleIndex.get(this._activeId);
868
- if (index !== undefined) {
869
- const rowTop = index * GETree.ROW_HEIGHT;
870
- const rowBottom = rowTop + GETree.ROW_HEIGHT;
871
- if (rowTop < this.scrollTop) this.scrollTop = rowTop;
872
- else if (rowBottom > this.scrollTop + this.clientHeight) this.scrollTop = rowBottom - this.clientHeight;
873
- this._render();
874
- rendered = this._rowCache.get(this._activeId);
875
- }
876
- }
877
- rendered?.row.scrollIntoView({ block: 'nearest' });
878
- }
879
-
880
- private _onScroll = (): void => {
881
- if (this._getVisibleFlatNodes().length >= GETree.VIRTUALIZATION_THRESHOLD) this._render();
882
- };
883
-
884
- private _isEditableTarget(target: EventTarget | null): boolean {
885
- if (!(target instanceof HTMLElement)) return false;
886
- const tag = target.tagName.toLowerCase();
887
- return tag === 'input' || tag === 'textarea' || tag === 'select' || target.isContentEditable;
888
- }
889
-
890
- private _copySelection(): boolean {
891
- const nodes = this._getSelectedTopLevelNodes();
892
- if (nodes.length === 0) return false;
893
- this._clipboard = nodes.map(node => this._cloneNode(node));
894
- return true;
895
- }
896
-
897
- private _pasteClipboard(): boolean {
898
- if (this._clipboard.length === 0) return false;
899
- const target = this._selectedId ? this._model.findNode(this._selectedId) : null;
900
- const targetList = target ? (target.node.children ??= []) : this._data;
901
- if (target) {
902
- this._expandedIds.add(target.node.id);
903
- }
904
-
905
- const existingIds = new Set(this._model.index.keys());
906
- const pastedNodes = this._clipboard.map(node => this._cloneNodeWithUniqueIds(node, existingIds));
907
- targetList.push(...pastedNodes);
908
- this._model.setData(this._data);
909
- const pastedIds = pastedNodes.map(node => node.id);
910
- this._setSelection(pastedIds, pastedIds[pastedIds.length - 1] ?? null, true);
911
- this.dispatchEvent(new CustomEvent<GETreeDataChangeDetail>('data-change', {
912
- detail: { data: this._data, action: 'paste', targetId: target?.node.id ?? null, pastedNodes },
913
- bubbles: true,
914
- composed: true,
915
- }));
916
- return true;
917
- }
918
-
919
- private _deleteSelection(): boolean {
920
- const ids = this._getTopLevelSelectedIds();
921
- if (ids.length === 0) return false;
922
- const deletedIds: string[] = [];
923
- for (const id of ids) {
924
- if (this._model.removeNode(id)) deletedIds.push(id);
925
- }
926
- if (deletedIds.length === 0) return false;
927
-
928
- this._model.setData(this._data);
929
- this._setSelection([], null, true);
930
- this.dispatchEvent(new CustomEvent<GETreeDataChangeDetail>('data-change', {
931
- detail: { data: this._data, action: 'delete', deletedIds },
932
- bubbles: true,
933
- composed: true,
934
- }));
935
- return true;
936
- }
937
-
938
- private _cloneNode(node: GETreeNodeData): GETreeNodeData {
939
- return typeof structuredClone === 'function'
940
- ? structuredClone(node)
941
- : JSON.parse(JSON.stringify(node)) as GETreeNodeData;
942
- }
943
-
944
- private _cloneNodeWithUniqueIds(node: GETreeNodeData, existingIds: Set<string>): GETreeNodeData {
945
- const clone = this._cloneNode(node);
946
- const assignIds = (item: GETreeNodeData) => {
947
- item.id = this._makeUniqueId(item.id, existingIds);
948
- existingIds.add(item.id);
949
- item.children?.forEach(assignIds);
950
- };
951
- assignIds(clone);
952
- return clone;
953
- }
954
-
955
- private _makeUniqueId(baseId: string, existingIds: Set<string>): string {
956
- const base = `${baseId}-copy`;
957
- if (!existingIds.has(base)) return base;
958
- let index = 2;
959
- while (existingIds.has(`${base}-${index}`)) index++;
960
- return `${base}-${index}`;
961
- }
962
-
963
- private _getSelectedTopLevelNodes(): GETreeNodeData[] {
964
- return this._getTopLevelSelectedIds()
965
- .map(id => this._model.getIndexedNode(id)?.node ?? null)
966
- .filter((node): node is GETreeNodeData => node !== null);
967
- }
968
-
969
- private _getTopLevelSelectedIds(): string[] {
970
- return this._model.topLevelSelection(this._selectedIds);
971
- }
972
-
973
- private _getDropPosition(event: DragEvent, row: HTMLElement): GETreeDropPosition {
974
- const rect = row.getBoundingClientRect();
975
- const y = event.clientY - rect.top;
976
- if (y < rect.height * 0.28) return 'before';
977
- if (y > rect.height * 0.72) return 'after';
978
- return 'inside';
979
- }
980
-
981
- private _getDropClass(event: DragEvent, row: HTMLElement): string {
982
- const position = this._getDropPosition(event, row);
983
- return `drop-${position}`;
984
- }
985
-
986
- private _clearDropClass(row: HTMLElement): void {
987
- row.classList.remove('drop-before', 'drop-after', 'drop-inside');
988
- }
989
-
990
- private _clearAllDropClasses(): void {
991
- this._list.querySelectorAll<HTMLElement>('.row').forEach(row => this._clearDropClass(row));
992
- }
993
- }
994
-
995
- export function defineTreeComponents(): void {
996
- if (!customElements.get('ge-tree-node')) {
997
- customElements.define('ge-tree-node', GETreeNode);
998
- }
999
- if (!customElements.get('ge-tree')) {
1000
- customElements.define('ge-tree', GETree);
1001
- }
1002
- }