@peter.prib/renderengine 0.2.3 → 0.2.4

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.
@@ -1,914 +1,886 @@
1
- import { treeModeRegistry } from '../../treeModeCatalog.js';
2
- import { CheckboxTreeSelector } from '../CheckboxTreeSelector.js';
3
- import { TreeBuilder } from './TreeBuilder.js';
4
- import { typeGlyph } from '../archimate/typeGlyph.js';
5
- import { ArchimateNotation } from '../../archimate.js';
6
- import { PopupMenu } from '../PopupMenu.js';
7
- import { TREE_LAYERS, treeLayerOf } from './treeLayers.js';
8
-
9
- export { TREE_LAYERS, treeLayerOf } from './treeLayers.js';
10
-
11
- /** The data type an element dragged from the model tree carries: its model element id. */
12
- export const MODEL_ELEMENT_MIME = 'application/x-archimate-element';
13
-
14
- /**
15
- * Tree context menus (folder, element, view, create), shown through the shared
16
- * PopupMenu. Items: `{ label, action }` or `{ label, submenu: { items } }`,
17
- * where submenu items may set `isActive` (checked) and `isSeparator` (rule above).
18
- */
19
- class ContextMenuManager {
20
- constructor(doc) {
21
- this.doc = doc;
22
- }
23
-
24
- show(event, items) {
25
- event.preventDefault();
26
- event.stopPropagation();
27
- const toPopup = (item) => (item.submenu
28
- ? { label: item.label, items: item.submenu.items.flatMap(sub => [
29
- ...(sub.isSeparator ? [{ separator: true }] : []),
30
- { label: sub.label, action: sub.action, checked: sub.isActive || undefined },
31
- ]) }
32
- : item); // plain items (separators, danger) are already PopupMenu items
33
- PopupMenu.show(event.clientX, event.clientY, items.map(toPopup), this.doc);
34
- }
35
- }
36
-
37
- /**
38
- * Manages tree state: selected nodes, expanded folders, etc.
39
- * Provides setState/getState methods and triggers re-render only on actual changes.
40
- */
41
- class TreeStateManager {
42
- constructor() {
43
- this.state = {
44
- selectedNodeId: null,
45
- expandedNodes: new Set(),
46
- };
47
- }
48
-
49
- /**
50
- * Update state. Returns true if state changed, false otherwise.
51
- */
52
- setState(updates) {
53
- let changed = false;
54
-
55
- if (updates.selectedNodeId !== undefined && updates.selectedNodeId !== this.state.selectedNodeId) {
56
- this.state.selectedNodeId = updates.selectedNodeId;
57
- changed = true;
58
- }
59
-
60
- if (updates.expandedNodes !== undefined) {
61
- const oldSize = this.state.expandedNodes.size;
62
- this.state.expandedNodes = new Set(updates.expandedNodes);
63
- if (oldSize !== this.state.expandedNodes.size) {
64
- changed = true;
65
- }
66
- }
67
-
68
- return changed;
69
- }
70
-
71
- /**
72
- * Get current state.
73
- */
74
- getState() {
75
- return {
76
- selectedNodeId: this.state.selectedNodeId,
77
- expandedNodes: new Set(this.state.expandedNodes),
78
- };
79
- }
80
-
81
- /**
82
- * Clear state.
83
- */
84
- clear() {
85
- this.state.selectedNodeId = null;
86
- this.state.expandedNodes.clear();
87
- }
88
- }
89
-
90
- /**
91
- * DOM shell for Archi-style model tree with folder hierarchy:
92
- * - Views folder at top level
93
- * - Layer/Type folders for elements (based on grouping mode)
94
- * Shows icons and element properties on click. The panel's `refresh()` redraws
95
- * the tree from the model (after an edit: a rename, an undo).
96
- */
97
- export function buildModelTreePanel(doc, { modelTree, onSelectElement = null, onNewView = null, treeDivId = 'model-tree', viewMenu = null, templateMenu = null, elementMenu = null, search = false, version = '4' } = {}) {
98
- // Store callbacks for use in event handlers. With a ViewMenu, views are
99
- // created, duplicated, renamed and deleted through the model; without one,
100
- // "New View" adds a view and hands it to onNewView. With a CanvasTemplateMenu,
101
- // canvases and templates (the model's, and its library's) get their menus.
102
- // elementMenu(element) adds items to an element's menu.
103
- const callbacks = { onNewView, viewMenu, templateMenu, elementMenu };
104
-
105
- const panel = doc.createElement('div');
106
- panel.style.cssText = `
107
- display: flex;
108
- flex-direction: column;
109
- height: 100%;
110
- gap: 8px;
111
- padding: 8px;
112
- font-family: sans-serif;
113
- font-size: 13px;
114
- overflow: hidden;
115
- `;
116
-
117
-
118
- // A search box (with `search`): the tree's text filter, which hides the elements it leaves out
119
- // here and, through modelTree.onFilterChange, whatever else listens (the canvas dims them).
120
- if (search) {
121
- const box = doc.createElement('input');
122
- Object.assign(box, { type: 'search', placeholder: '🔍 Search the model', value: modelTree.getFilterText?.() ?? '' });
123
- box.setAttribute('data-tree-search', '');
124
- box.style.cssText = 'width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid #ccc; border-radius: 3px; font-size: 13px;';
125
- box.addEventListener('input', () => { modelTree.setFilter(box.value.trim()); showMatches(); });
126
- panel.appendChild(box);
127
- }
128
-
129
- // Filter button opens modal dialog
130
- const filterBtnRow = doc.createElement('div');
131
- filterBtnRow.style.cssText = 'margin-bottom: 8px;';
132
-
133
- const filterBtn = doc.createElement('button');
134
- filterBtn.type = 'button';
135
- filterBtn.textContent = '🔍 Filters…';
136
- filterBtn.style.cssText = 'width: 100%; padding: 6px; font-size: 13px; font-weight: bold; background: #f5f5f5; border: 1px solid #ccc; border-radius: 3px; cursor: pointer;';
137
- filterBtnRow.appendChild(filterBtn);
138
- panel.appendChild(filterBtnRow);
139
-
140
- // Helper: build fresh filter area content (called each time modal opens to stay in sync with model changes)
141
- function buildFilterContent() {
142
- const filterArea = doc.createElement('div');
143
- filterArea.style.cssText = `
144
- display: flex;
145
- flex-direction: column;
146
- gap: 8px;
147
- `;
148
-
149
- // Type/Specialization filter (collapsible checkbox tree)
150
- const typeTreeData = modelTree.getAvailableTypeTree();
151
- if (typeTreeData && typeTreeData.length > 0) {
152
- const typeFilterContainer = doc.createElement('div');
153
- typeFilterContainer.style.cssText = 'padding: 4px; border-bottom: 1px solid #e0e0e0;';
154
-
155
- const typeHeaderRow = doc.createElement('div');
156
- typeHeaderRow.style.cssText = 'display: flex; gap: 4px; align-items: center; justify-content: space-between; margin-bottom: 4px;';
157
-
158
- const typeDetails = doc.createElement('details');
159
- typeDetails.style.cssText = 'flex: 1;';
160
- typeDetails.open = true;
161
-
162
- const typeSummary = doc.createElement('summary');
163
- typeSummary.style.cssText = 'cursor: pointer; font-weight: bold; user-select: none; padding: 2px 4px;';
164
- typeSummary.textContent = '▼ Type/Specialization';
165
- typeDetails.appendChild(typeSummary);
166
-
167
- // Rename labels for better UX (strip common prefixes)
168
- const cleanTreeData = _cleanTreeLabels(typeTreeData);
169
-
170
- const typeSelector = new CheckboxTreeSelector({ doc, nodes: cleanTreeData });
171
- const typeContainer = typeSelector.build();
172
- typeContainer.style.cssText = 'padding-left: 8px; font-size: 12px;';
173
- typeDetails.appendChild(typeContainer);
174
-
175
- const typeResetBtn = doc.createElement('button');
176
- typeResetBtn.type = 'button';
177
- typeResetBtn.textContent = 'Reset';
178
- typeResetBtn.style.cssText = 'padding: 2px 6px; font-size: 11px; border: 1px solid #ccc; border-radius: 2px; cursor: pointer; background: #fff; color: #333;';
179
- typeResetBtn.addEventListener('click', () => {
180
- typeSelector.setCheckedIds(null);
181
- modelTree.setTypeFilter(null);
182
- modelTree.setSpecializationFilter(null);
183
- rebuildTree();
184
- });
185
-
186
- typeHeaderRow.appendChild(typeDetails);
187
- typeHeaderRow.appendChild(typeResetBtn);
188
- typeFilterContainer.appendChild(typeHeaderRow);
189
-
190
- typeSelector.onChange((checkedIds) => {
191
- // CheckboxTreeSelector handles cascading automatically
192
- const typeIds = new Set([...checkedIds].filter(id => id.startsWith('type:')));
193
- const specIds = new Set([...checkedIds].filter(id => id.startsWith('spec:')));
194
- modelTree.setTypeFilter(typeIds.size > 0 ? typeIds : null);
195
- modelTree.setSpecializationFilter(specIds.size > 0 ? specIds : null);
196
- rebuildTree();
197
- });
198
-
199
- filterArea.appendChild(typeFilterContainer);
200
- }
201
-
202
- // Property filter (collapsible checkbox tree)
203
- const propTreeData = modelTree.getAvailablePropertyTree();
204
- if (propTreeData && propTreeData.length > 0) {
205
- const propFilterContainer = doc.createElement('div');
206
- propFilterContainer.style.cssText = 'padding: 4px;';
207
-
208
- const propHeaderRow = doc.createElement('div');
209
- propHeaderRow.style.cssText = 'display: flex; gap: 4px; align-items: center; justify-content: space-between; margin-bottom: 4px;';
210
-
211
- const propDetails = doc.createElement('details');
212
- propDetails.style.cssText = 'flex: 1;';
213
- propDetails.open = true;
214
-
215
- const propSummary = doc.createElement('summary');
216
- propSummary.style.cssText = 'cursor: pointer; font-weight: bold; user-select: none; padding: 2px 4px;';
217
- propSummary.textContent = '▼ Properties';
218
- propDetails.appendChild(propSummary);
219
-
220
- const propSelector = new CheckboxTreeSelector({ doc, nodes: propTreeData });
221
- const propContainer = propSelector.build();
222
- propContainer.style.cssText = 'padding-left: 8px; font-size: 12px;';
223
- propDetails.appendChild(propContainer);
224
-
225
- const propResetBtn = doc.createElement('button');
226
- propResetBtn.type = 'button';
227
- propResetBtn.textContent = 'Reset';
228
- propResetBtn.style.cssText = 'padding: 2px 6px; font-size: 11px; border: 1px solid #ccc; border-radius: 2px; cursor: pointer; background: #fff; color: #333;';
229
- propResetBtn.addEventListener('click', () => {
230
- propSelector.setCheckedIds(null);
231
- modelTree.setPropertyFilter(null);
232
- rebuildTree();
233
- });
234
-
235
- propHeaderRow.appendChild(propDetails);
236
- propHeaderRow.appendChild(propResetBtn);
237
- propFilterContainer.appendChild(propHeaderRow);
238
-
239
- propSelector.onChange((checkedIds) => {
240
- const propIds = new Set([...checkedIds].filter(id => id.startsWith('propval:')));
241
- modelTree.setPropertyFilter(propIds.size > 0 ? propIds : null);
242
- rebuildTree();
243
- });
244
-
245
- propFilterContainer.style.cssText = 'padding: 4px;';
246
- filterArea.appendChild(propFilterContainer);
247
- }
248
-
249
- return filterArea;
250
- }
251
-
252
- // Wire up filter button to open modal with fresh filter content
253
- filterBtn.addEventListener('click', () => {
254
- const filterContent = buildFilterContent();
255
- if (filterContent.hasChildNodes()) {
256
- showFiltersModal(doc, filterContent);
257
- } else {
258
- alert('No filters available (no types or properties in model)');
259
- }
260
- });
261
-
262
- // Helper: show filters in a modal dialog
263
- function showFiltersModal(doc, filterAreaContent) {
264
- // Modal backdrop
265
- const backdrop = doc.createElement('div');
266
- backdrop.style.cssText = `
267
- position: fixed;
268
- top: 0;
269
- left: 0;
270
- right: 0;
271
- bottom: 0;
272
- background: rgba(0, 0, 0, 0.3);
273
- z-index: 9999;
274
- `;
275
-
276
- // Modal dialog
277
- const modal = doc.createElement('div');
278
- modal.style.cssText = `
279
- position: fixed;
280
- top: 50%;
281
- left: 50%;
282
- transform: translate(-50%, -50%);
283
- width: 90%;
284
- max-width: 500px;
285
- max-height: 70vh;
286
- background: #fff;
287
- border: 1px solid #ccc;
288
- border-radius: 6px;
289
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
290
- z-index: 10000;
291
- display: flex;
292
- flex-direction: column;
293
- overflow: hidden;
294
- `;
295
-
296
- // Header
297
- const header = doc.createElement('div');
298
- header.style.cssText = `
299
- padding: 12px 16px;
300
- border-bottom: 1px solid #e0e0e0;
301
- display: flex;
302
- align-items: center;
303
- justify-content: space-between;
304
- font-weight: bold;
305
- font-size: 14px;
306
- flex-shrink: 0;
307
- `;
308
- header.textContent = 'Filters';
309
-
310
- const closeBtn = doc.createElement('button');
311
- closeBtn.type = 'button';
312
- closeBtn.textContent = '✕';
313
- closeBtn.style.cssText = 'background: none; border: none; font-size: 18px; cursor: pointer; color: #999;';
314
- closeBtn.addEventListener('click', () => {
315
- backdrop.remove();
316
- modal.remove();
317
- });
318
- header.appendChild(closeBtn);
319
-
320
- // Content (scrollable, contains the actual filterArea with event listeners)
321
- const content = doc.createElement('div');
322
- content.style.cssText = `
323
- flex: 1;
324
- overflow-y: auto;
325
- padding: 12px 16px;
326
- font-size: 13px;
327
- `;
328
- // Move the actual filterArea into the modal (keeps event listeners intact)
329
- content.appendChild(filterAreaContent);
330
-
331
- modal.appendChild(header);
332
- modal.appendChild(content);
333
-
334
- backdrop.addEventListener('click', () => {
335
- backdrop.remove();
336
- modal.remove();
337
- });
338
-
339
- doc.body.appendChild(backdrop);
340
- doc.body.appendChild(modal);
341
- }
342
-
343
- // Tree container
344
- const treeDiv = doc.createElement('div');
345
- treeDiv.id = treeDivId;
346
- treeDiv.style.cssText = `
347
- flex: 1;
348
- overflow-y: auto;
349
- border: 1px solid #ccc;
350
- border-radius: 4px;
351
- padding: 4px;
352
- display: flex;
353
- flex-direction: column;
354
- `;
355
-
356
- panel.appendChild(treeDiv);
357
- // An element leaf (TreeBuilder marks it with data-element-id) drags its element's id; an editor's drop handler takes it.
358
- treeDiv.addEventListener('dragstart', (event) => {
359
- const node = event.target.closest?.('[data-element-id]');
360
- if (!node) return;
361
- event.dataTransfer.effectAllowed = 'copy';
362
- event.dataTransfer.setData(MODEL_ELEMENT_MIME, node.getAttribute('data-element-id'));
363
- node.style.opacity = '0.5';
364
- });
365
- treeDiv.addEventListener('dragend', (event) => {
366
- const node = event.target.closest?.('[data-element-id]');
367
- if (node) node.style.opacity = '';
368
- });
369
-
370
- // Initialize managers
371
- const contextMenuManager = new ContextMenuManager(doc);
372
- const treeStateManager = new TreeStateManager();
373
-
374
- // Helper: clean type labels for better UX (strip layer prefixes)
375
- // E.g., "business-actor" → "actor", "application-service" → "service"
376
- function _cleanTreeLabels(nodes) {
377
- return nodes.map(node => {
378
- let label = node.label;
379
- // For types (e.g., 'business-actor'), strip the layer prefix
380
- if (node.id && node.id.startsWith('type:')) {
381
- const typeName = node.id.substring(5); // remove 'type:'
382
- const parts = typeName.split('-');
383
- // Skip the first part if it looks like a layer name (single word)
384
- if (parts.length > 1 && ['business', 'application', 'technology', 'implementation', 'motivation', 'strategy', 'physical'].includes(parts[0])) {
385
- label = parts.slice(1).join('-');
386
- }
387
- }
388
- // Recursively clean children
389
- if (node.children) {
390
- return { ...node, label, children: _cleanTreeLabels(node.children) };
391
- }
392
- return { ...node, label };
393
- });
394
- }
395
-
396
- /**
397
- * Prepare data: fetch views, folders, elements from model.
398
- * Returns { views, model, folders, foldersByParent, topLevelFolders, allElements, elementsByLayer }
399
- */
400
- function prepareData() {
401
- const model = modelTree.model;
402
- // Canvases and templates have folders of their own (a model without them shows every view here).
403
- const views = model?.diagramViews?.() ?? modelTree.getAvailableViews();
404
- const canvases = model?.canvases?.() ?? null;
405
- const templates = model?.templates?.() ?? null;
406
-
407
- // Get ALL folders including nested ones
408
- const allFolders = model?.allFolders?.() || [];
409
- const foldersByParent = {};
410
- const topLevelFolders = [];
411
- for (const folder of allFolders) {
412
- if (!folder.parentId) {
413
- topLevelFolders.push(folder);
414
- } else {
415
- if (!foldersByParent[folder.parentId]) {
416
- foldersByParent[folder.parentId] = [];
417
- }
418
- foldersByParent[folder.parentId].push(folder);
419
- }
420
- }
421
-
422
- const allElements = model?.elements?.() || [];
423
- const elementsByLayer = {};
424
- for (const element of allElements) {
425
- const layer = treeLayerOf(element);
426
- if (!elementsByLayer[layer]) {
427
- elementsByLayer[layer] = [];
428
- }
429
- elementsByLayer[layer].push(element);
430
- }
431
-
432
- return {
433
- views,
434
- canvases,
435
- templates,
436
- model,
437
- allFolders,
438
- foldersByParent,
439
- topLevelFolders,
440
- allElements,
441
- elementsByLayer,
442
- };
443
- }
444
-
445
- /**
446
- * Build tree structure: create rootNodes from prepared data.
447
- */
448
- function buildTreeStructure(preparedData) {
449
- const { views, canvases, templates, elementsByLayer, topLevelFolders, foldersByParent } = preparedData;
450
-
451
- const rootNodes = [];
452
-
453
- // Views category
454
- rootNodes.push({
455
- id: 'category-views',
456
- label: 'Views',
457
- icon: '📄',
458
- type: 'category-views',
459
- _views: views,
460
- _topLevelFolders: topLevelFolders
461
- });
462
-
463
- // Canvases (in Archi's sub-folders) and templates (the model's, then the library's)
464
- if (canvases) {
465
- rootNodes.push({ id: 'category-canvases', label: 'Canvases', icon: '🎨', type: 'category-canvases', _views: canvases, _depth: 0 });
466
- // Closed until opened: the library's templates are many, and not what a model is about.
467
- rootNodes.push({ id: 'category-templates', label: 'Templates', icon: '📋', type: 'category-templates', _views: templates, collapsed: true });
468
- }
469
-
470
- // Layers category
471
- rootNodes.push({
472
- id: 'category-layers',
473
- label: 'Layers',
474
- icon: '📚',
475
- type: 'category-layers',
476
- _elementsByLayer: elementsByLayer
477
- });
478
-
479
- // Return data needed for getNodeChildren
480
- return {
481
- rootNodes,
482
- foldersByParent,
483
- topLevelFolders,
484
- };
485
- }
486
-
487
- /**
488
- * Render tree: use TreeBuilder to render the structure.
489
- */
490
- function renderTree(treeStructure, preparedData) {
491
- const { rootNodes, foldersByParent, topLevelFolders } = treeStructure;
492
- const { model } = preparedData;
493
-
494
- treeDiv.innerHTML = '';
495
-
496
- const categoriesContainer = doc.createElement('div');
497
- categoriesContainer.className = 'categories-tree-container';
498
- categoriesContainer.style.cssText = 'flex: 1; overflow-y: auto;';
499
- treeDiv.appendChild(categoriesContainer);
500
-
501
- const categoriesBuilder = new TreeBuilder(doc, {
502
- container: categoriesContainer,
503
- rootNodes: rootNodes,
504
- openState: modelTree.openFolders ?? new Map(),
505
- // An element shows its type's glyph (as the palette does), and so does a folder of one type.
506
- iconFor: (node) => {
507
- const type = node.type === 'element' ? node._element?.type : node.type === 'type-group' ? node._elements?.[0]?.type : null;
508
- return type && (ArchimateNotation.elementType(type, version) || ArchimateNotation.elementType(type, '3.2')) ? typeGlyph(type, { size: 12, version, document: doc }) : null;
509
- },
510
- onNodeClick: (nodeId, node) => {
511
- if (node.type === 'element' && node._element) {
512
- treeStateManager.setState({ selectedNodeId: nodeId });
513
- modelTree.select(node._element.id, 'element');
514
- if (onSelectElement) {
515
- onSelectElement({ id: node._element.id, kind: 'element', data: node._element });
516
- }
517
- } else if (node.type === 'view' && node._view) {
518
- treeStateManager.setState({ selectedNodeId: nodeId });
519
- modelTree.select(node._view.id, 'view');
520
- }
521
- },
522
- onNodeContext: (event, node) => {
523
- if (node.type === 'folder') {
524
- _showFolderContextMenu(event, node._model, { model });
525
- } else if (node.type === 'element' && node._element) {
526
- _showElementContextMenu(event, node._element, { model });
527
- } else if (node.type === 'view' && node._view && (callbacks.viewMenu || callbacks.templateMenu)) {
528
- const id = node._view.id;
529
- const own = callbacks.viewMenu?.items(id) ?? [];
530
- const templateItems = callbacks.templateMenu
531
- ? [...callbacks.templateMenu.saveAsTemplateItems(id), ...callbacks.templateMenu.modelTemplateItems(id)]
532
- : [];
533
- contextMenuManager.show(event, [...templateItems, ...(templateItems.length && own.length ? [{ separator: true }] : []), ...own]);
534
- } else if (node.type === 'category-canvases' || node.type === 'canvas-folder') {
535
- if (callbacks.templateMenu) contextMenuManager.show(event, callbacks.templateMenu.newCanvasItems());
536
- } else if (node.type === 'category-templates' || node.type === 'template-library') {
537
- const items = [...(callbacks.templateMenu?.newCanvasItems().slice(1) ?? []), callbacks.templateMenu?.importItem()].filter(Boolean);
538
- if (items.length) contextMenuManager.show(event, items);
539
- } else if (node.type === 'library-template') {
540
- contextMenuManager.show(event, callbacks.templateMenu.libraryEntryItems(node._entry.id));
541
- } else if (node.type === 'category-views') {
542
- _showViewsCategoryMenu(event, { model });
543
- } else if (node.type === 'category-layers') {
544
- // Layers category is read-only, no context menu
545
- event.preventDefault();
546
- event.stopPropagation();
547
- } else if (node.type === 'layer') {
548
- // Allow creating folders under layers
549
- _showLayerContextMenu(event, node, { model });
550
- }
551
- },
552
- getNodeChildren: (node) => {
553
- if (node.type === 'category-views') {
554
- const children = [];
555
- for (const view of node._views) {
556
- children.push({
557
- id: view.id,
558
- label: view.name || 'Untitled View',
559
- icon: '📄',
560
- type: 'view',
561
- _view: view
562
- });
563
- }
564
- for (const folder of topLevelFolders) {
565
- children.push({
566
- id: folder.id,
567
- label: folder.name || 'Untitled Folder',
568
- icon: '📁',
569
- type: 'folder',
570
- _model: folder
571
- });
572
- }
573
- return children.length > 0 ? children : [];
574
- } else if (node.type === 'category-canvases' || node.type === 'canvas-folder') {
575
- return canvasFolderChildren(node._views, node._depth);
576
- } else if (node.type === 'category-templates') {
577
- const children = node._views.map(view => ({ id: view.id, label: view.name || 'Untitled Template', icon: '📋', type: 'view', _view: view }));
578
- if (callbacks.templateMenu?.library) {
579
- children.push({ id: 'template-library', label: 'Library', icon: '📚', type: 'template-library' });
580
- }
581
- return children;
582
- } else if (node.type === 'template-library') {
583
- return callbacks.templateMenu.library.list().map(entry => ({
584
- id: `library-${entry.id}`, label: entry.name || 'Untitled Template', icon: '📋', type: 'library-template', _entry: entry,
585
- }));
586
- } else if (node.type === 'category-layers') {
587
- const children = [];
588
- for (const layerName of TREE_LAYERS) {
589
- if (node._elementsByLayer[layerName] && node._elementsByLayer[layerName].length > 0) {
590
- const layerLabel = layerName.charAt(0).toUpperCase() + layerName.slice(1);
591
- const elementsByType = {};
592
- for (const element of node._elementsByLayer[layerName]) {
593
- const typeKey = element.type || 'other';
594
- if (!elementsByType[typeKey]) {
595
- elementsByType[typeKey] = [];
596
- }
597
- elementsByType[typeKey].push(element);
598
- }
599
- children.push({
600
- id: `layer-${layerName}`,
601
- label: layerLabel,
602
- icon: '📁',
603
- type: 'layer',
604
- _layerName: layerName,
605
- _elementsByType: elementsByType
606
- });
607
- }
608
- }
609
- return children;
610
- } else if (node.type === 'layer') {
611
- const children = [];
612
-
613
- // Add folders that belong to this layer
614
- const allFolders = preparedData.allFolders || [];
615
- for (const folder of allFolders) {
616
- if (folder._layerName === node._layerName) {
617
- children.push({
618
- id: folder.id,
619
- label: folder.name || 'Untitled Folder',
620
- icon: '📁',
621
- type: 'folder',
622
- _model: folder
623
- });
624
- }
625
- }
626
-
627
- // Add type groups for elements
628
- for (const typeKey in node._elementsByType) {
629
- const elements = node._elementsByType[typeKey];
630
- const parts = typeKey.split('-');
631
- const words = parts.length > 1 ? parts.slice(1) : parts;
632
- const typeName = words.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
633
- children.push({
634
- id: `type-${typeKey}`,
635
- label: typeName,
636
- icon: '📁',
637
- type: 'type-group',
638
- _elements: elements
639
- });
640
- }
641
- return children.length > 0 ? children : null;
642
- } else if (node.type === 'type-group') {
643
- const children = [];
644
- for (const element of node._elements) {
645
- children.push({
646
- id: element.id,
647
- label: element.name || 'Untitled',
648
- icon: '●',
649
- type: 'element',
650
- _element: element
651
- });
652
- }
653
- return children.length > 0 ? children : null;
654
- } else if (node.type === 'folder') {
655
- const children = foldersByParent[node.id];
656
- if (children) {
657
- return children.map(f => ({
658
- id: f.id,
659
- label: f.name || 'Untitled Folder',
660
- icon: '📁',
661
- type: 'folder',
662
- _model: f
663
- }));
664
- }
665
- return null;
666
- }
667
- return null;
668
- }
669
- });
670
-
671
- categoriesBuilder.render();
672
- }
673
-
674
- /**
675
- * Canvases at one depth of Archi's sub-folders (`archiFolder`, a path of
676
- * folder steps): a folder node per sub-folder, then the canvases that are here.
677
- */
678
- function canvasFolderChildren(views, depth) {
679
- const folders = new Map();
680
- const here = [];
681
- for (const view of views) {
682
- const step = view.archiFolder?.[depth];
683
- if (!step) { here.push(view); continue; }
684
- if (!folders.has(step.id)) folders.set(step.id, { step, views: [] });
685
- folders.get(step.id).views.push(view);
686
- }
687
- return [
688
- ...[...folders.values()].map(({ step, views: inside }) => ({
689
- id: `canvas-folder-${step.id}`, label: step.name || 'Untitled Folder', icon: '📁', type: 'canvas-folder', _views: inside, _depth: depth + 1,
690
- })),
691
- ...here.map(view => ({ id: view.id, label: view.name || 'Untitled Canvas', icon: '🎨', type: 'view', _view: view })),
692
- ];
693
- }
694
-
695
- /**
696
- * Rebuild tree: data fetch → structure build → render.
697
- */
698
- function rebuildTree() {
699
- const preparedData = prepareData();
700
- const treeStructure = buildTreeStructure(preparedData);
701
- renderTree(treeStructure, preparedData);
702
- showMatches();
703
- }
704
-
705
- /** Hide the element leaves the tree's filters leave out (all shown with no filter on). */
706
- function showMatches() {
707
- const keep = modelTree.matchingElementIds?.() ?? null;
708
- for (const leaf of treeDiv.querySelectorAll('[data-element-id]')) {
709
- // A leaf lays itself out as a flex row (TreeBuilder): shown again, it is that, not unset.
710
- const shown = !keep || keep.has(leaf.getAttribute('data-element-id'));
711
- if (!shown) leaf.style.display = 'none';
712
- else if (leaf.style.display === 'none') leaf.style.display = 'flex';
713
- }
714
- }
715
-
716
- /**
717
- * Show context menu for layer (right-click on a layer).
718
- * Allows creating folders under this layer.
719
- */
720
- function _showLayerContextMenu(event, layerNode, { model }) {
721
- const items = [
722
- {
723
- label: 'Add Folder',
724
- action: () => {
725
- const folderName = window.prompt('Folder name:', 'New Folder');
726
- if (folderName && folderName.trim()) {
727
- const folderId = `folder-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
728
- // Mark this folder as belonging to a specific layer
729
- const newFolder = {
730
- id: folderId,
731
- name: folderName.trim(),
732
- documentation: '',
733
- parentId: undefined,
734
- children: [],
735
- _layerName: layerNode._layerName // Track which layer owns this folder
736
- };
737
- model._folders.set(folderId, newFolder);
738
- rebuildTree();
739
- }
740
- }
741
- },
742
- ];
743
-
744
- contextMenuManager.show(event, items);
745
- }
746
-
747
- /**
748
- * Show context menu for folder (right-click on a folder item).
749
- * Uses ContextMenuManager for consistent handling.
750
- */
751
- function _showFolderContextMenu(event, folder, { model }) {
752
- const items = [
753
- {
754
- label: 'New Subfolder…',
755
- action: () => {
756
- const subfolderName = window.prompt('Subfolder name:', 'New Folder');
757
- if (subfolderName && subfolderName.trim()) {
758
- const folderId = `folder-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
759
- const newFolder = {
760
- id: folderId,
761
- name: subfolderName.trim(),
762
- documentation: '',
763
- parentId: folder.id,
764
- children: []
765
- };
766
- model._folders.set(folderId, newFolder);
767
- // Update parent's children array for proper traversal
768
- folder.children = folder.children || [];
769
- folder.children.push(folderId);
770
- rebuildTree();
771
- }
772
- }
773
- },
774
- {
775
- label: 'Rename…',
776
- action: () => {
777
- const newName = window.prompt('Rename folder:', folder.name || 'Folder');
778
- if (newName && newName.trim() && newName.trim() !== folder.name) {
779
- folder.name = newName.trim();
780
- rebuildTree();
781
- }
782
- }
783
- },
784
- {
785
- label: 'Delete…',
786
- action: () => {
787
- if (window.confirm(`Delete folder "${folder.name || 'Untitled Folder'}"? Elements will not be deleted.`)) {
788
- const parentId = folder.parentId;
789
- const allFolders = model.allFolders?.() || [];
790
- for (const f of allFolders) {
791
- if (f.parentId === folder.id) {
792
- f.parentId = parentId || undefined;
793
- }
794
- }
795
- model._folders.delete(folder.id);
796
- rebuildTree();
797
- }
798
- }
799
- },
800
- ];
801
-
802
- contextMenuManager.show(event, items);
803
- }
804
-
805
- /**
806
- * Show context menu for Views category (right-click on "Views").
807
- * Allows creating new views and new top-level folders within Views.
808
- */
809
- function _showViewsCategoryMenu(event, { model }) {
810
- const items = [
811
- callbacks.viewMenu ? callbacks.viewMenu.newViewItem() : {
812
- label: 'New View',
813
- action: () => {
814
- const viewName = window.prompt('View name:', 'New View');
815
- if (viewName && viewName.trim()) {
816
- const viewId = `view-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
817
- const newView = model.addView({
818
- id: viewId,
819
- name: viewName.trim(),
820
- elementIds: [], // Start with empty canvas, not all elements
821
- viewpoint: '',
822
- viewpointMode: '',
823
- layout: {},
824
- edgeLayout: {},
825
- folderLayout: {}
826
- });
827
- rebuildTree();
828
- // Call the onNewView callback to create a tab for this view
829
- callbacks.onNewView?.(newView);
830
- }
831
- }
832
- },
833
- callbacks.viewMenu && { separator: true },
834
- {
835
- label: 'New Folder',
836
- action: () => {
837
- const folderName = window.prompt('Folder name:', 'New Folder');
838
- if (folderName && folderName.trim()) {
839
- const folderId = `folder-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
840
- const newFolder = {
841
- id: folderId,
842
- name: folderName.trim(),
843
- documentation: '',
844
- parentId: undefined,
845
- children: []
846
- };
847
- model._folders.set(folderId, newFolder);
848
- rebuildTree();
849
- }
850
- }
851
- },
852
- ].filter(Boolean);
853
-
854
- contextMenuManager.show(event, items);
855
- }
856
-
857
- /**
858
- * Show context menu for element (right-click on an element in tree).
859
- * Uses ContextMenuManager with submenu support for "Move to Folder".
860
- */
861
- function _showElementContextMenu(event, element, { model }) {
862
- const folders = model?.folders?.() || [];
863
- const currentFolder = model?.getElementFolder?.(element.id);
864
-
865
- const own = callbacks.elementMenu?.(element) ?? [];
866
- const items = [...own, ...(own.length ? [{ separator: true }] : [])];
867
-
868
- // "Move to Folder" with submenu if folders exist
869
- if (folders.length > 0) {
870
- const folderItems = [];
871
-
872
- for (const folder of folders) {
873
- folderItems.push({
874
- label: folder.name || 'Untitled Folder',
875
- isActive: currentFolder === folder.id,
876
- action: () => {
877
- model.setElementFolder?.(element.id, folder.id);
878
- rebuildTree();
879
- }
880
- });
881
- }
882
-
883
- // "Remove from folder" option
884
- folderItems.push({
885
- label: '(No Folder)',
886
- isActive: !currentFolder,
887
- isSeparator: true,
888
- action: () => {
889
- model.setElementFolder?.(element.id, undefined);
890
- rebuildTree();
891
- }
892
- });
893
-
894
- items.push({
895
- label: 'Move to Folder',
896
- submenu: { items: folderItems }
897
- });
898
- }
899
-
900
- // No rule at the end (when there were no folders to move to).
901
- if (items.at(-1)?.separator) items.pop();
902
- if (items.length) contextMenuManager.show(event, items);
903
- }
904
-
905
- // Initial render (deferred to ensure model is ready and filters are reset)
906
- setTimeout(() => {
907
- modelTree.setTypeFilter(null);
908
- modelTree.setSpecializationFilter(null);
909
- modelTree.setPropertyFilter(null);
910
- rebuildTree();
911
- }, 0);
912
- panel.refresh = () => rebuildTree();
913
- return panel;
914
- }
1
+ import { treeModeRegistry } from '../../treeModeCatalog.js';
2
+ import { CheckboxTreeSelector } from '../CheckboxTreeSelector.js';
3
+ import { TreeBuilder } from './TreeBuilder.js';
4
+ import { typeGlyph } from '../archimate/typeGlyph.js';
5
+ import { ArchimateNotation } from '../../archimate.js';
6
+ import { PopupMenu } from '../PopupMenu.js';
7
+ import { TREE_LAYERS, treeLayerOf } from './treeLayers.js';
8
+ import { viewFolderNodes } from './viewFolders.js';
9
+
10
+ export { TREE_LAYERS, treeLayerOf } from './treeLayers.js';
11
+
12
+ /** The data type an element dragged from the model tree carries: its model element id. */
13
+ export const MODEL_ELEMENT_MIME = 'application/x-archimate-element';
14
+
15
+ /**
16
+ * Tree context menus (folder, element, view, create), shown through the shared
17
+ * PopupMenu. Items: `{ label, action }` or `{ label, submenu: { items } }`,
18
+ * where submenu items may set `isActive` (checked) and `isSeparator` (rule above).
19
+ */
20
+ class ContextMenuManager {
21
+ constructor(doc) {
22
+ this.doc = doc;
23
+ }
24
+
25
+ show(event, items) {
26
+ event.preventDefault();
27
+ event.stopPropagation();
28
+ const toPopup = (item) => (item.submenu
29
+ ? { label: item.label, items: item.submenu.items.flatMap(sub => [
30
+ ...(sub.isSeparator ? [{ separator: true }] : []),
31
+ { label: sub.label, action: sub.action, checked: sub.isActive || undefined },
32
+ ]) }
33
+ : item); // plain items (separators, danger) are already PopupMenu items
34
+ PopupMenu.show(event.clientX, event.clientY, items.map(toPopup), this.doc);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Manages tree state: selected nodes, expanded folders, etc.
40
+ * Provides setState/getState methods and triggers re-render only on actual changes.
41
+ */
42
+ class TreeStateManager {
43
+ constructor() {
44
+ this.state = {
45
+ selectedNodeId: null,
46
+ expandedNodes: new Set(),
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Update state. Returns true if state changed, false otherwise.
52
+ */
53
+ setState(updates) {
54
+ let changed = false;
55
+
56
+ if (updates.selectedNodeId !== undefined && updates.selectedNodeId !== this.state.selectedNodeId) {
57
+ this.state.selectedNodeId = updates.selectedNodeId;
58
+ changed = true;
59
+ }
60
+
61
+ if (updates.expandedNodes !== undefined) {
62
+ const oldSize = this.state.expandedNodes.size;
63
+ this.state.expandedNodes = new Set(updates.expandedNodes);
64
+ if (oldSize !== this.state.expandedNodes.size) {
65
+ changed = true;
66
+ }
67
+ }
68
+
69
+ return changed;
70
+ }
71
+
72
+ /**
73
+ * Get current state.
74
+ */
75
+ getState() {
76
+ return {
77
+ selectedNodeId: this.state.selectedNodeId,
78
+ expandedNodes: new Set(this.state.expandedNodes),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Clear state.
84
+ */
85
+ clear() {
86
+ this.state.selectedNodeId = null;
87
+ this.state.expandedNodes.clear();
88
+ }
89
+ }
90
+
91
+ /**
92
+ * DOM shell for Archi-style model tree with folder hierarchy:
93
+ * - Views folder at top level
94
+ * - Layer/Type folders for elements (based on grouping mode)
95
+ * Shows icons and element properties on click. The panel's `refresh()` redraws
96
+ * the tree from the model (after an edit: a rename, an undo).
97
+ */
98
+ export function buildModelTreePanel(doc, { modelTree, onSelectElement = null, onNewView = null, treeDivId = 'model-tree', viewMenu = null, templateMenu = null, elementMenu = null, search = false, version = '4' } = {}) {
99
+ // Store callbacks for use in event handlers. With a ViewMenu, views are
100
+ // created, duplicated, renamed and deleted through the model; without one,
101
+ // "New View" adds a view and hands it to onNewView. With a CanvasTemplateMenu,
102
+ // canvases and templates (the model's, and its library's) get their menus.
103
+ // elementMenu(element) adds items to an element's menu.
104
+ const callbacks = { onNewView, viewMenu, templateMenu, elementMenu };
105
+
106
+ const panel = doc.createElement('div');
107
+ panel.style.cssText = `
108
+ display: flex;
109
+ flex-direction: column;
110
+ height: 100%;
111
+ gap: 8px;
112
+ padding: 8px;
113
+ font-family: sans-serif;
114
+ font-size: 13px;
115
+ overflow: hidden;
116
+ `;
117
+
118
+
119
+ // A search box (with `search`): the tree's text filter, which hides the elements it leaves out
120
+ // here and, through modelTree.onFilterChange, whatever else listens (the canvas dims them).
121
+ if (search) {
122
+ const box = doc.createElement('input');
123
+ Object.assign(box, { type: 'search', placeholder: '🔍 Search the model', value: modelTree.getFilterText?.() ?? '' });
124
+ box.setAttribute('data-tree-search', '');
125
+ box.style.cssText = 'width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid #ccc; border-radius: 3px; font-size: 13px;';
126
+ box.addEventListener('input', () => { modelTree.setFilter(box.value.trim()); showMatches(); });
127
+ panel.appendChild(box);
128
+ }
129
+
130
+ // Filter button opens modal dialog
131
+ const filterBtnRow = doc.createElement('div');
132
+ filterBtnRow.style.cssText = 'margin-bottom: 8px;';
133
+
134
+ const filterBtn = doc.createElement('button');
135
+ filterBtn.type = 'button';
136
+ filterBtn.textContent = '🔍 Filters…';
137
+ filterBtn.style.cssText = 'width: 100%; padding: 6px; font-size: 13px; font-weight: bold; background: #f5f5f5; border: 1px solid #ccc; border-radius: 3px; cursor: pointer;';
138
+ filterBtnRow.appendChild(filterBtn);
139
+ panel.appendChild(filterBtnRow);
140
+
141
+ // Helper: build fresh filter area content (called each time modal opens to stay in sync with model changes)
142
+ function buildFilterContent() {
143
+ const filterArea = doc.createElement('div');
144
+ filterArea.style.cssText = `
145
+ display: flex;
146
+ flex-direction: column;
147
+ gap: 8px;
148
+ `;
149
+
150
+ // Type/Specialization filter (collapsible checkbox tree)
151
+ const typeTreeData = modelTree.getAvailableTypeTree();
152
+ if (typeTreeData && typeTreeData.length > 0) {
153
+ const typeFilterContainer = doc.createElement('div');
154
+ typeFilterContainer.style.cssText = 'padding: 4px; border-bottom: 1px solid #e0e0e0;';
155
+
156
+ const typeHeaderRow = doc.createElement('div');
157
+ typeHeaderRow.style.cssText = 'display: flex; gap: 4px; align-items: center; justify-content: space-between; margin-bottom: 4px;';
158
+
159
+ const typeDetails = doc.createElement('details');
160
+ typeDetails.style.cssText = 'flex: 1;';
161
+ typeDetails.open = true;
162
+
163
+ const typeSummary = doc.createElement('summary');
164
+ typeSummary.style.cssText = 'cursor: pointer; font-weight: bold; user-select: none; padding: 2px 4px;';
165
+ typeSummary.textContent = '▼ Type/Specialization';
166
+ typeDetails.appendChild(typeSummary);
167
+
168
+ // Rename labels for better UX (strip common prefixes)
169
+ const cleanTreeData = _cleanTreeLabels(typeTreeData);
170
+
171
+ const typeSelector = new CheckboxTreeSelector({ doc, nodes: cleanTreeData });
172
+ const typeContainer = typeSelector.build();
173
+ typeContainer.style.cssText = 'padding-left: 8px; font-size: 12px;';
174
+ typeDetails.appendChild(typeContainer);
175
+
176
+ const typeResetBtn = doc.createElement('button');
177
+ typeResetBtn.type = 'button';
178
+ typeResetBtn.textContent = 'Reset';
179
+ typeResetBtn.style.cssText = 'padding: 2px 6px; font-size: 11px; border: 1px solid #ccc; border-radius: 2px; cursor: pointer; background: #fff; color: #333;';
180
+ typeResetBtn.addEventListener('click', () => {
181
+ typeSelector.setCheckedIds(null);
182
+ modelTree.setTypeFilter(null);
183
+ modelTree.setSpecializationFilter(null);
184
+ rebuildTree();
185
+ });
186
+
187
+ typeHeaderRow.appendChild(typeDetails);
188
+ typeHeaderRow.appendChild(typeResetBtn);
189
+ typeFilterContainer.appendChild(typeHeaderRow);
190
+
191
+ typeSelector.onChange((checkedIds) => {
192
+ // CheckboxTreeSelector handles cascading automatically
193
+ const typeIds = new Set([...checkedIds].filter(id => id.startsWith('type:')));
194
+ const specIds = new Set([...checkedIds].filter(id => id.startsWith('spec:')));
195
+ modelTree.setTypeFilter(typeIds.size > 0 ? typeIds : null);
196
+ modelTree.setSpecializationFilter(specIds.size > 0 ? specIds : null);
197
+ rebuildTree();
198
+ });
199
+
200
+ filterArea.appendChild(typeFilterContainer);
201
+ }
202
+
203
+ // Property filter (collapsible checkbox tree)
204
+ const propTreeData = modelTree.getAvailablePropertyTree();
205
+ if (propTreeData && propTreeData.length > 0) {
206
+ const propFilterContainer = doc.createElement('div');
207
+ propFilterContainer.style.cssText = 'padding: 4px;';
208
+
209
+ const propHeaderRow = doc.createElement('div');
210
+ propHeaderRow.style.cssText = 'display: flex; gap: 4px; align-items: center; justify-content: space-between; margin-bottom: 4px;';
211
+
212
+ const propDetails = doc.createElement('details');
213
+ propDetails.style.cssText = 'flex: 1;';
214
+ propDetails.open = true;
215
+
216
+ const propSummary = doc.createElement('summary');
217
+ propSummary.style.cssText = 'cursor: pointer; font-weight: bold; user-select: none; padding: 2px 4px;';
218
+ propSummary.textContent = '▼ Properties';
219
+ propDetails.appendChild(propSummary);
220
+
221
+ const propSelector = new CheckboxTreeSelector({ doc, nodes: propTreeData });
222
+ const propContainer = propSelector.build();
223
+ propContainer.style.cssText = 'padding-left: 8px; font-size: 12px;';
224
+ propDetails.appendChild(propContainer);
225
+
226
+ const propResetBtn = doc.createElement('button');
227
+ propResetBtn.type = 'button';
228
+ propResetBtn.textContent = 'Reset';
229
+ propResetBtn.style.cssText = 'padding: 2px 6px; font-size: 11px; border: 1px solid #ccc; border-radius: 2px; cursor: pointer; background: #fff; color: #333;';
230
+ propResetBtn.addEventListener('click', () => {
231
+ propSelector.setCheckedIds(null);
232
+ modelTree.setPropertyFilter(null);
233
+ rebuildTree();
234
+ });
235
+
236
+ propHeaderRow.appendChild(propDetails);
237
+ propHeaderRow.appendChild(propResetBtn);
238
+ propFilterContainer.appendChild(propHeaderRow);
239
+
240
+ propSelector.onChange((checkedIds) => {
241
+ const propIds = new Set([...checkedIds].filter(id => id.startsWith('propval:')));
242
+ modelTree.setPropertyFilter(propIds.size > 0 ? propIds : null);
243
+ rebuildTree();
244
+ });
245
+
246
+ propFilterContainer.style.cssText = 'padding: 4px;';
247
+ filterArea.appendChild(propFilterContainer);
248
+ }
249
+
250
+ return filterArea;
251
+ }
252
+
253
+ // Wire up filter button to open modal with fresh filter content
254
+ filterBtn.addEventListener('click', () => {
255
+ const filterContent = buildFilterContent();
256
+ if (filterContent.hasChildNodes()) {
257
+ showFiltersModal(doc, filterContent);
258
+ } else {
259
+ alert('No filters available (no types or properties in model)');
260
+ }
261
+ });
262
+
263
+ // Helper: show filters in a modal dialog
264
+ function showFiltersModal(doc, filterAreaContent) {
265
+ // Modal backdrop
266
+ const backdrop = doc.createElement('div');
267
+ backdrop.style.cssText = `
268
+ position: fixed;
269
+ top: 0;
270
+ left: 0;
271
+ right: 0;
272
+ bottom: 0;
273
+ background: rgba(0, 0, 0, 0.3);
274
+ z-index: 9999;
275
+ `;
276
+
277
+ // Modal dialog
278
+ const modal = doc.createElement('div');
279
+ modal.style.cssText = `
280
+ position: fixed;
281
+ top: 50%;
282
+ left: 50%;
283
+ transform: translate(-50%, -50%);
284
+ width: 90%;
285
+ max-width: 500px;
286
+ max-height: 70vh;
287
+ background: #fff;
288
+ border: 1px solid #ccc;
289
+ border-radius: 6px;
290
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
291
+ z-index: 10000;
292
+ display: flex;
293
+ flex-direction: column;
294
+ overflow: hidden;
295
+ `;
296
+
297
+ // Header
298
+ const header = doc.createElement('div');
299
+ header.style.cssText = `
300
+ padding: 12px 16px;
301
+ border-bottom: 1px solid #e0e0e0;
302
+ display: flex;
303
+ align-items: center;
304
+ justify-content: space-between;
305
+ font-weight: bold;
306
+ font-size: 14px;
307
+ flex-shrink: 0;
308
+ `;
309
+ header.textContent = 'Filters';
310
+
311
+ const closeBtn = doc.createElement('button');
312
+ closeBtn.type = 'button';
313
+ closeBtn.textContent = '✕';
314
+ closeBtn.style.cssText = 'background: none; border: none; font-size: 18px; cursor: pointer; color: #999;';
315
+ closeBtn.addEventListener('click', () => {
316
+ backdrop.remove();
317
+ modal.remove();
318
+ });
319
+ header.appendChild(closeBtn);
320
+
321
+ // Content (scrollable, contains the actual filterArea with event listeners)
322
+ const content = doc.createElement('div');
323
+ content.style.cssText = `
324
+ flex: 1;
325
+ overflow-y: auto;
326
+ padding: 12px 16px;
327
+ font-size: 13px;
328
+ `;
329
+ // Move the actual filterArea into the modal (keeps event listeners intact)
330
+ content.appendChild(filterAreaContent);
331
+
332
+ modal.appendChild(header);
333
+ modal.appendChild(content);
334
+
335
+ backdrop.addEventListener('click', () => {
336
+ backdrop.remove();
337
+ modal.remove();
338
+ });
339
+
340
+ doc.body.appendChild(backdrop);
341
+ doc.body.appendChild(modal);
342
+ }
343
+
344
+ // Tree container
345
+ const treeDiv = doc.createElement('div');
346
+ treeDiv.id = treeDivId;
347
+ treeDiv.style.cssText = `
348
+ flex: 1;
349
+ overflow-y: auto;
350
+ border: 1px solid #ccc;
351
+ border-radius: 4px;
352
+ padding: 4px;
353
+ display: flex;
354
+ flex-direction: column;
355
+ `;
356
+
357
+ panel.appendChild(treeDiv);
358
+ // An element leaf (TreeBuilder marks it with data-element-id) drags its element's id; an editor's drop handler takes it.
359
+ treeDiv.addEventListener('dragstart', (event) => {
360
+ const node = event.target.closest?.('[data-element-id]');
361
+ if (!node) return;
362
+ event.dataTransfer.effectAllowed = 'copy';
363
+ event.dataTransfer.setData(MODEL_ELEMENT_MIME, node.getAttribute('data-element-id'));
364
+ node.style.opacity = '0.5';
365
+ });
366
+ treeDiv.addEventListener('dragend', (event) => {
367
+ const node = event.target.closest?.('[data-element-id]');
368
+ if (node) node.style.opacity = '';
369
+ });
370
+
371
+ // Initialize managers
372
+ const contextMenuManager = new ContextMenuManager(doc);
373
+ const treeStateManager = new TreeStateManager();
374
+
375
+ // Helper: clean type labels for better UX (strip layer prefixes)
376
+ // E.g., "business-actor" → "actor", "application-service" → "service"
377
+ function _cleanTreeLabels(nodes) {
378
+ return nodes.map(node => {
379
+ let label = node.label;
380
+ // For types (e.g., 'business-actor'), strip the layer prefix
381
+ if (node.id && node.id.startsWith('type:')) {
382
+ const typeName = node.id.substring(5); // remove 'type:'
383
+ const parts = typeName.split('-');
384
+ // Skip the first part if it looks like a layer name (single word)
385
+ if (parts.length > 1 && ['business', 'application', 'technology', 'implementation', 'motivation', 'strategy', 'physical'].includes(parts[0])) {
386
+ label = parts.slice(1).join('-');
387
+ }
388
+ }
389
+ // Recursively clean children
390
+ if (node.children) {
391
+ return { ...node, label, children: _cleanTreeLabels(node.children) };
392
+ }
393
+ return { ...node, label };
394
+ });
395
+ }
396
+
397
+ /**
398
+ * Prepare data: fetch views, folders, elements from model.
399
+ * Returns { views, model, folders, foldersByParent, topLevelFolders, allElements, elementsByLayer }
400
+ */
401
+ function prepareData() {
402
+ const model = modelTree.model;
403
+ // Every view but the templates (they have a folder of their own), canvases too, as Archi's Views folder holds them.
404
+ const templates = model?.templates?.() ?? null;
405
+ const views = templates ? model.views().filter(v => !v.template) : modelTree.getAvailableViews();
406
+
407
+ // Get ALL folders including nested ones
408
+ const allFolders = model?.allFolders?.() || [];
409
+ const foldersByParent = {};
410
+ const topLevelFolders = [];
411
+ for (const folder of allFolders) {
412
+ if (!folder.parentId) {
413
+ topLevelFolders.push(folder);
414
+ } else {
415
+ if (!foldersByParent[folder.parentId]) {
416
+ foldersByParent[folder.parentId] = [];
417
+ }
418
+ foldersByParent[folder.parentId].push(folder);
419
+ }
420
+ }
421
+
422
+ const allElements = model?.elements?.() || [];
423
+ const elementsByLayer = {};
424
+ for (const element of allElements) {
425
+ const layer = treeLayerOf(element);
426
+ if (!elementsByLayer[layer]) {
427
+ elementsByLayer[layer] = [];
428
+ }
429
+ elementsByLayer[layer].push(element);
430
+ }
431
+
432
+ return {
433
+ views,
434
+ templates,
435
+ model,
436
+ allFolders,
437
+ foldersByParent,
438
+ topLevelFolders,
439
+ allElements,
440
+ elementsByLayer,
441
+ };
442
+ }
443
+
444
+ /**
445
+ * Build tree structure: create rootNodes from prepared data.
446
+ */
447
+ function buildTreeStructure(preparedData) {
448
+ const { views, templates, elementsByLayer, topLevelFolders, foldersByParent } = preparedData;
449
+
450
+ const rootNodes = [];
451
+
452
+ // Views category: the views in Archi's sub-folders
453
+ rootNodes.push({
454
+ id: 'category-views',
455
+ label: 'Views',
456
+ icon: '📄',
457
+ type: 'category-views',
458
+ _views: views,
459
+ _depth: 0,
460
+ _topLevelFolders: topLevelFolders
461
+ });
462
+
463
+ // Templates (the model's, then the library's)
464
+ if (templates) {
465
+ // Closed until opened: the library's templates are many, and not what a model is about.
466
+ rootNodes.push({ id: 'category-templates', label: 'Templates', icon: '📋', type: 'category-templates', _views: templates, collapsed: true });
467
+ }
468
+
469
+ // Layers category
470
+ rootNodes.push({
471
+ id: 'category-layers',
472
+ label: 'Layers',
473
+ icon: '📚',
474
+ type: 'category-layers',
475
+ _elementsByLayer: elementsByLayer
476
+ });
477
+
478
+ // Return data needed for getNodeChildren
479
+ return {
480
+ rootNodes,
481
+ foldersByParent,
482
+ topLevelFolders,
483
+ };
484
+ }
485
+
486
+ /**
487
+ * Render tree: use TreeBuilder to render the structure.
488
+ */
489
+ function renderTree(treeStructure, preparedData) {
490
+ const { rootNodes, foldersByParent, topLevelFolders } = treeStructure;
491
+ const { model } = preparedData;
492
+
493
+ treeDiv.innerHTML = '';
494
+
495
+ const categoriesContainer = doc.createElement('div');
496
+ categoriesContainer.className = 'categories-tree-container';
497
+ categoriesContainer.style.cssText = 'flex: 1; overflow-y: auto;';
498
+ treeDiv.appendChild(categoriesContainer);
499
+
500
+ const categoriesBuilder = new TreeBuilder(doc, {
501
+ container: categoriesContainer,
502
+ rootNodes: rootNodes,
503
+ openState: modelTree.openFolders ?? new Map(),
504
+ // An element shows its type's glyph (as the palette does), and so does a folder of one type.
505
+ iconFor: (node) => {
506
+ const type = node.type === 'element' ? node._element?.type : node.type === 'type-group' ? node._elements?.[0]?.type : null;
507
+ return type && (ArchimateNotation.elementType(type, version) || ArchimateNotation.elementType(type, '3.2')) ? typeGlyph(type, { size: 12, version, document: doc }) : null;
508
+ },
509
+ onNodeClick: (nodeId, node) => {
510
+ if (node.type === 'element' && node._element) {
511
+ treeStateManager.setState({ selectedNodeId: nodeId });
512
+ modelTree.select(node._element.id, 'element');
513
+ if (onSelectElement) {
514
+ onSelectElement({ id: node._element.id, kind: 'element', data: node._element });
515
+ }
516
+ } else if (node.type === 'view' && node._view) {
517
+ treeStateManager.setState({ selectedNodeId: nodeId });
518
+ modelTree.select(node._view.id, 'view');
519
+ }
520
+ },
521
+ onNodeContext: (event, node) => {
522
+ if (node.type === 'folder') {
523
+ _showFolderContextMenu(event, node._model, { model });
524
+ } else if (node.type === 'element' && node._element) {
525
+ _showElementContextMenu(event, node._element, { model });
526
+ } else if (node.type === 'view' && node._view && (callbacks.viewMenu || callbacks.templateMenu)) {
527
+ const id = node._view.id;
528
+ const own = callbacks.viewMenu?.items(id) ?? [];
529
+ const templateItems = callbacks.templateMenu
530
+ ? [...callbacks.templateMenu.saveAsTemplateItems(id), ...callbacks.templateMenu.modelTemplateItems(id)]
531
+ : [];
532
+ contextMenuManager.show(event, [...templateItems, ...(templateItems.length && own.length ? [{ separator: true }] : []), ...own]);
533
+ } else if (node.type === 'view-folder') {
534
+ _showViewsCategoryMenu(event, { model, folder: true });
535
+ } else if (node.type === 'category-templates' || node.type === 'template-library') {
536
+ const items = [...(callbacks.templateMenu?.newCanvasItems().slice(1) ?? []), callbacks.templateMenu?.importItem()].filter(Boolean);
537
+ if (items.length) contextMenuManager.show(event, items);
538
+ } else if (node.type === 'library-template') {
539
+ contextMenuManager.show(event, callbacks.templateMenu.libraryEntryItems(node._entry.id));
540
+ } else if (node.type === 'category-views') {
541
+ _showViewsCategoryMenu(event, { model });
542
+ } else if (node.type === 'category-layers') {
543
+ // Layers category is read-only, no context menu
544
+ event.preventDefault();
545
+ event.stopPropagation();
546
+ } else if (node.type === 'layer') {
547
+ // Allow creating folders under layers
548
+ _showLayerContextMenu(event, node, { model });
549
+ }
550
+ },
551
+ getNodeChildren: (node) => {
552
+ if (node.type === 'category-views') {
553
+ const children = viewFolderNodes(node._views, node._depth);
554
+ for (const folder of topLevelFolders) {
555
+ children.push({
556
+ id: folder.id,
557
+ label: folder.name || 'Untitled Folder',
558
+ icon: '📁',
559
+ type: 'folder',
560
+ _model: folder
561
+ });
562
+ }
563
+ return children.length > 0 ? children : [];
564
+ } else if (node.type === 'view-folder') {
565
+ return viewFolderNodes(node._views, node._depth);
566
+ } else if (node.type === 'category-templates') {
567
+ const children = node._views.map(view => ({ id: view.id, label: view.name || 'Untitled Template', icon: '📋', type: 'view', _view: view }));
568
+ if (callbacks.templateMenu?.library) {
569
+ children.push({ id: 'template-library', label: 'Library', icon: '📚', type: 'template-library' });
570
+ }
571
+ return children;
572
+ } else if (node.type === 'template-library') {
573
+ return callbacks.templateMenu.library.list().map(entry => ({
574
+ id: `library-${entry.id}`, label: entry.name || 'Untitled Template', icon: '📋', type: 'library-template', _entry: entry,
575
+ }));
576
+ } else if (node.type === 'category-layers') {
577
+ const children = [];
578
+ for (const layerName of TREE_LAYERS) {
579
+ if (node._elementsByLayer[layerName] && node._elementsByLayer[layerName].length > 0) {
580
+ const layerLabel = layerName.charAt(0).toUpperCase() + layerName.slice(1);
581
+ const elementsByType = {};
582
+ for (const element of node._elementsByLayer[layerName]) {
583
+ const typeKey = element.type || 'other';
584
+ if (!elementsByType[typeKey]) {
585
+ elementsByType[typeKey] = [];
586
+ }
587
+ elementsByType[typeKey].push(element);
588
+ }
589
+ children.push({
590
+ id: `layer-${layerName}`,
591
+ label: layerLabel,
592
+ icon: '📁',
593
+ type: 'layer',
594
+ _layerName: layerName,
595
+ _elementsByType: elementsByType
596
+ });
597
+ }
598
+ }
599
+ return children;
600
+ } else if (node.type === 'layer') {
601
+ const children = [];
602
+
603
+ // Add folders that belong to this layer
604
+ const allFolders = preparedData.allFolders || [];
605
+ for (const folder of allFolders) {
606
+ if (folder._layerName === node._layerName) {
607
+ children.push({
608
+ id: folder.id,
609
+ label: folder.name || 'Untitled Folder',
610
+ icon: '📁',
611
+ type: 'folder',
612
+ _model: folder
613
+ });
614
+ }
615
+ }
616
+
617
+ // Add type groups for elements
618
+ for (const typeKey in node._elementsByType) {
619
+ const elements = node._elementsByType[typeKey];
620
+ const parts = typeKey.split('-');
621
+ const words = parts.length > 1 ? parts.slice(1) : parts;
622
+ const typeName = words.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
623
+ children.push({
624
+ id: `type-${typeKey}`,
625
+ label: typeName,
626
+ icon: '📁',
627
+ type: 'type-group',
628
+ _elements: elements
629
+ });
630
+ }
631
+ return children.length > 0 ? children : null;
632
+ } else if (node.type === 'type-group') {
633
+ const children = [];
634
+ for (const element of node._elements) {
635
+ children.push({
636
+ id: element.id,
637
+ label: element.name || 'Untitled',
638
+ icon: '●',
639
+ type: 'element',
640
+ _element: element
641
+ });
642
+ }
643
+ return children.length > 0 ? children : null;
644
+ } else if (node.type === 'folder') {
645
+ const children = foldersByParent[node.id];
646
+ if (children) {
647
+ return children.map(f => ({
648
+ id: f.id,
649
+ label: f.name || 'Untitled Folder',
650
+ icon: '📁',
651
+ type: 'folder',
652
+ _model: f
653
+ }));
654
+ }
655
+ return null;
656
+ }
657
+ return null;
658
+ }
659
+ });
660
+
661
+ categoriesBuilder.render();
662
+ }
663
+
664
+ /**
665
+ * Rebuild tree: data fetch → structure build → render.
666
+ */
667
+ function rebuildTree() {
668
+ const preparedData = prepareData();
669
+ const treeStructure = buildTreeStructure(preparedData);
670
+ renderTree(treeStructure, preparedData);
671
+ showMatches();
672
+ }
673
+
674
+ /** Hide the element leaves the tree's filters leave out (all shown with no filter on). */
675
+ function showMatches() {
676
+ const keep = modelTree.matchingElementIds?.() ?? null;
677
+ for (const leaf of treeDiv.querySelectorAll('[data-element-id]')) {
678
+ // A leaf lays itself out as a flex row (TreeBuilder): shown again, it is that, not unset.
679
+ const shown = !keep || keep.has(leaf.getAttribute('data-element-id'));
680
+ if (!shown) leaf.style.display = 'none';
681
+ else if (leaf.style.display === 'none') leaf.style.display = 'flex';
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Show context menu for layer (right-click on a layer).
687
+ * Allows creating folders under this layer.
688
+ */
689
+ function _showLayerContextMenu(event, layerNode, { model }) {
690
+ const items = [
691
+ {
692
+ label: 'Add Folder',
693
+ action: () => {
694
+ const folderName = window.prompt('Folder name:', 'New Folder');
695
+ if (folderName && folderName.trim()) {
696
+ const folderId = `folder-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
697
+ // Mark this folder as belonging to a specific layer
698
+ const newFolder = {
699
+ id: folderId,
700
+ name: folderName.trim(),
701
+ documentation: '',
702
+ parentId: undefined,
703
+ children: [],
704
+ _layerName: layerNode._layerName // Track which layer owns this folder
705
+ };
706
+ model._folders.set(folderId, newFolder);
707
+ rebuildTree();
708
+ }
709
+ }
710
+ },
711
+ ];
712
+
713
+ contextMenuManager.show(event, items);
714
+ }
715
+
716
+ /**
717
+ * Show context menu for folder (right-click on a folder item).
718
+ * Uses ContextMenuManager for consistent handling.
719
+ */
720
+ function _showFolderContextMenu(event, folder, { model }) {
721
+ const items = [
722
+ {
723
+ label: 'New Subfolder…',
724
+ action: () => {
725
+ const subfolderName = window.prompt('Subfolder name:', 'New Folder');
726
+ if (subfolderName && subfolderName.trim()) {
727
+ const folderId = `folder-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
728
+ const newFolder = {
729
+ id: folderId,
730
+ name: subfolderName.trim(),
731
+ documentation: '',
732
+ parentId: folder.id,
733
+ children: []
734
+ };
735
+ model._folders.set(folderId, newFolder);
736
+ // Update parent's children array for proper traversal
737
+ folder.children = folder.children || [];
738
+ folder.children.push(folderId);
739
+ rebuildTree();
740
+ }
741
+ }
742
+ },
743
+ {
744
+ label: 'Rename…',
745
+ action: () => {
746
+ const newName = window.prompt('Rename folder:', folder.name || 'Folder');
747
+ if (newName && newName.trim() && newName.trim() !== folder.name) {
748
+ folder.name = newName.trim();
749
+ rebuildTree();
750
+ }
751
+ }
752
+ },
753
+ {
754
+ label: 'Delete…',
755
+ action: () => {
756
+ if (window.confirm(`Delete folder "${folder.name || 'Untitled Folder'}"? Elements will not be deleted.`)) {
757
+ const parentId = folder.parentId;
758
+ const allFolders = model.allFolders?.() || [];
759
+ for (const f of allFolders) {
760
+ if (f.parentId === folder.id) {
761
+ f.parentId = parentId || undefined;
762
+ }
763
+ }
764
+ model._folders.delete(folder.id);
765
+ rebuildTree();
766
+ }
767
+ }
768
+ },
769
+ ];
770
+
771
+ contextMenuManager.show(event, items);
772
+ }
773
+
774
+ /**
775
+ * Show context menu for Views category (right-click on "Views", or one
776
+ * of its sub-folders): new views and canvases; on Views itself, new
777
+ * top-level folders too.
778
+ */
779
+ function _showViewsCategoryMenu(event, { model, folder = false }) {
780
+ const items = [
781
+ callbacks.viewMenu ? callbacks.viewMenu.newViewItem() : {
782
+ label: 'New View',
783
+ action: () => {
784
+ const viewName = window.prompt('View name:', 'New View');
785
+ if (viewName && viewName.trim()) {
786
+ const viewId = `view-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
787
+ const newView = model.addView({
788
+ id: viewId,
789
+ name: viewName.trim(),
790
+ elementIds: [], // Start with empty canvas, not all elements
791
+ viewpoint: '',
792
+ viewpointMode: '',
793
+ layout: {},
794
+ edgeLayout: {},
795
+ folderLayout: {}
796
+ });
797
+ rebuildTree();
798
+ // Call the onNewView callback to create a tab for this view
799
+ callbacks.onNewView?.(newView);
800
+ }
801
+ }
802
+ },
803
+ // With a CanvasTemplateMenu: a new canvas, blank or from a template, here too.
804
+ ...(callbacks.templateMenu?.newCanvasItems() ?? []),
805
+ callbacks.viewMenu && !folder && { separator: true },
806
+ !folder && {
807
+ label: 'New Folder',
808
+ action: () => {
809
+ const folderName = window.prompt('Folder name:', 'New Folder');
810
+ if (folderName && folderName.trim()) {
811
+ const folderId = `folder-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
812
+ const newFolder = {
813
+ id: folderId,
814
+ name: folderName.trim(),
815
+ documentation: '',
816
+ parentId: undefined,
817
+ children: []
818
+ };
819
+ model._folders.set(folderId, newFolder);
820
+ rebuildTree();
821
+ }
822
+ }
823
+ },
824
+ ].filter(Boolean);
825
+
826
+ contextMenuManager.show(event, items);
827
+ }
828
+
829
+ /**
830
+ * Show context menu for element (right-click on an element in tree).
831
+ * Uses ContextMenuManager with submenu support for "Move to Folder".
832
+ */
833
+ function _showElementContextMenu(event, element, { model }) {
834
+ const folders = model?.folders?.() || [];
835
+ const currentFolder = model?.getElementFolder?.(element.id);
836
+
837
+ const own = callbacks.elementMenu?.(element) ?? [];
838
+ const items = [...own, ...(own.length ? [{ separator: true }] : [])];
839
+
840
+ // "Move to Folder" with submenu if folders exist
841
+ if (folders.length > 0) {
842
+ const folderItems = [];
843
+
844
+ for (const folder of folders) {
845
+ folderItems.push({
846
+ label: folder.name || 'Untitled Folder',
847
+ isActive: currentFolder === folder.id,
848
+ action: () => {
849
+ model.setElementFolder?.(element.id, folder.id);
850
+ rebuildTree();
851
+ }
852
+ });
853
+ }
854
+
855
+ // "Remove from folder" option
856
+ folderItems.push({
857
+ label: '(No Folder)',
858
+ isActive: !currentFolder,
859
+ isSeparator: true,
860
+ action: () => {
861
+ model.setElementFolder?.(element.id, undefined);
862
+ rebuildTree();
863
+ }
864
+ });
865
+
866
+ items.push({
867
+ label: 'Move to Folder',
868
+ submenu: { items: folderItems }
869
+ });
870
+ }
871
+
872
+ // No rule at the end (when there were no folders to move to).
873
+ if (items.at(-1)?.separator) items.pop();
874
+ if (items.length) contextMenuManager.show(event, items);
875
+ }
876
+
877
+ // Initial render (deferred to ensure model is ready and filters are reset)
878
+ setTimeout(() => {
879
+ modelTree.setTypeFilter(null);
880
+ modelTree.setSpecializationFilter(null);
881
+ modelTree.setPropertyFilter(null);
882
+ rebuildTree();
883
+ }, 0);
884
+ panel.refresh = () => rebuildTree();
885
+ return panel;
886
+ }