@peter.prib/renderengine 0.5.0 → 0.7.0

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