cyclecad 0.1.3 → 0.1.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.
@@ -0,0 +1,741 @@
1
+ /**
2
+ * project-browser.js - Project folder browser UI for cycleCAD
3
+ * Displays Inventor project structure with file tree, search, and file selection
4
+ */
5
+
6
+ let browserState = {
7
+ containerEl: null,
8
+ overlayEl: null,
9
+ projectData: null,
10
+ searchQuery: '',
11
+ expandedFolders: new Set(),
12
+ onFileSelectCallback: null,
13
+ currentPath: [],
14
+ };
15
+
16
+ /**
17
+ * Initialize the project browser
18
+ * @param {HTMLElement} container - Container element for the browser overlay
19
+ * @param {Object} callbacks - Callback object with onFileOpen(file), onProjectLoad(project)
20
+ */
21
+ export function initProjectBrowser(container, callbacks = {}) {
22
+ browserState.containerEl = container;
23
+ browserState.onFileSelectCallback = callbacks.onFileSelect || callbacks.onFileOpen || (() => {});
24
+
25
+ injectStyles();
26
+ createBrowserPanel();
27
+ }
28
+
29
+ /**
30
+ * Show the project browser overlay
31
+ */
32
+ export function showBrowser() {
33
+ if (browserState.overlayEl) {
34
+ browserState.overlayEl.style.display = 'flex';
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Hide the project browser overlay
40
+ */
41
+ export function hideBrowser() {
42
+ if (browserState.overlayEl) {
43
+ browserState.overlayEl.style.display = 'none';
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Set project data and populate the browser tree
49
+ * @param {Object} projectData - Project structure from project-loader
50
+ */
51
+ export function setProject(projectData) {
52
+ browserState.projectData = projectData;
53
+ browserState.expandedFolders.clear();
54
+ browserState.currentPath = [];
55
+ browserState.searchQuery = '';
56
+
57
+ // Reset search input
58
+ const searchInput = document.getElementById('project-search');
59
+ if (searchInput) searchInput.value = '';
60
+
61
+ renderTree();
62
+ updateStats();
63
+ }
64
+
65
+ /**
66
+ * Register file selection callback
67
+ * @param {Function} callback - Called with selected file object {name, path, type}
68
+ */
69
+ export function onFileSelect(callback) {
70
+ browserState.onFileSelectCallback = callback;
71
+ }
72
+
73
+ /**
74
+ * Create the browser panel HTML structure
75
+ */
76
+ function createBrowserPanel() {
77
+ const overlay = document.createElement('div');
78
+ overlay.id = 'project-browser-overlay';
79
+ overlay.className = 'project-browser-overlay';
80
+
81
+ overlay.innerHTML = `
82
+ <div class="project-browser-panel">
83
+ <!-- Header -->
84
+ <div class="pb-header">
85
+ <div class="pb-title">Project Browser</div>
86
+ <button class="pb-close-btn" aria-label="Close browser">✕</button>
87
+ </div>
88
+
89
+ <!-- Stats Bar -->
90
+ <div class="pb-stats">
91
+ <span class="stat-item"><span class="stat-value" id="stat-files">0</span> files</span>
92
+ <span class="stat-item"><span class="stat-value" id="stat-parts">0</span> parts</span>
93
+ <span class="stat-item"><span class="stat-value" id="stat-asms">0</span> assemblies</span>
94
+ </div>
95
+
96
+ <!-- Breadcrumb Navigation -->
97
+ <div class="pb-breadcrumb">
98
+ <button class="breadcrumb-item" data-path="">Project</button>
99
+ <div id="breadcrumb-trail"></div>
100
+ </div>
101
+
102
+ <!-- Search Input -->
103
+ <div class="pb-search-box">
104
+ <input
105
+ type="text"
106
+ id="project-search"
107
+ class="pb-search-input"
108
+ placeholder="Search files..."
109
+ aria-label="Search project files"
110
+ >
111
+ </div>
112
+
113
+ <!-- Tree Container -->
114
+ <div class="pb-tree-container">
115
+ <div id="project-tree" class="pb-tree"></div>
116
+ <div id="pb-empty" class="pb-empty">
117
+ <p>No project loaded</p>
118
+ <p class="pb-empty-hint">Use "Open Project" to load an Inventor file</p>
119
+ </div>
120
+ </div>
121
+
122
+ <!-- Footer with Actions -->
123
+ <div class="pb-footer">
124
+ <button class="pb-btn pb-btn-primary" id="open-project-btn">
125
+ 📁 Open Project
126
+ </button>
127
+ </div>
128
+ </div>
129
+ `;
130
+
131
+ browserState.containerEl.appendChild(overlay);
132
+ browserState.overlayEl = overlay;
133
+
134
+ // Event listeners
135
+ setupEventListeners();
136
+ }
137
+
138
+ /**
139
+ * Setup all event listeners for the browser
140
+ */
141
+ function setupEventListeners() {
142
+ const overlay = browserState.overlayEl;
143
+
144
+ // Close button
145
+ overlay.querySelector('.pb-close-btn').addEventListener('click', hideBrowser);
146
+
147
+ // Search input with debounce
148
+ const searchInput = overlay.querySelector('#project-search');
149
+ let searchTimeout;
150
+ searchInput.addEventListener('input', (e) => {
151
+ clearTimeout(searchTimeout);
152
+ searchTimeout = setTimeout(() => {
153
+ browserState.searchQuery = e.target.value.toLowerCase();
154
+ renderTree();
155
+ }, 300);
156
+ });
157
+
158
+ // Tree delegated click handler
159
+ overlay.querySelector('#project-tree').addEventListener('click', (e) => {
160
+ const target = e.target.closest('[data-tree-item]');
161
+ if (!target) return;
162
+
163
+ const itemId = target.dataset.treeItem;
164
+ const itemType = target.dataset.itemType;
165
+ const filePath = target.dataset.filePath;
166
+
167
+ if (itemType === 'folder') {
168
+ // Toggle folder expansion
169
+ if (browserState.expandedFolders.has(itemId)) {
170
+ browserState.expandedFolders.delete(itemId);
171
+ } else {
172
+ browserState.expandedFolders.add(itemId);
173
+ }
174
+ renderTree();
175
+ } else if (['ipt', 'iam', 'idw'].includes(itemType)) {
176
+ // File selection
177
+ const fileObj = {
178
+ name: target.textContent.trim().split('\n')[0],
179
+ path: filePath,
180
+ type: itemType,
181
+ };
182
+ browserState.onFileSelectCallback(fileObj);
183
+ hideBrowser();
184
+ }
185
+ });
186
+
187
+ // Breadcrumb navigation
188
+ overlay.addEventListener('click', (e) => {
189
+ if (e.target.classList.contains('breadcrumb-item')) {
190
+ const path = e.target.dataset.path;
191
+ browserState.currentPath = path ? path.split('/').filter(Boolean) : [];
192
+ renderTree();
193
+ }
194
+ });
195
+
196
+ // Open project button
197
+ overlay.querySelector('#open-project-btn').addEventListener('click', () => {
198
+ // This would typically call project-loader to open file picker
199
+ dispatchCustomEvent('open-project-requested');
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Render the project tree structure
205
+ */
206
+ function renderTree() {
207
+ const treeContainer = browserState.overlayEl.querySelector('#project-tree');
208
+ const emptyState = browserState.overlayEl.querySelector('#pb-empty');
209
+
210
+ if (!browserState.projectData) {
211
+ treeContainer.innerHTML = '';
212
+ emptyState.style.display = 'flex';
213
+ return;
214
+ }
215
+
216
+ emptyState.style.display = 'none';
217
+
218
+ // Get current folder contents
219
+ const currentFolder = navigateToPath(browserState.projectData, browserState.currentPath);
220
+ if (!currentFolder || !currentFolder.children) {
221
+ treeContainer.innerHTML = '';
222
+ return;
223
+ }
224
+
225
+ const html = renderTreeItems(currentFolder.children);
226
+ treeContainer.innerHTML = html;
227
+ }
228
+
229
+ /**
230
+ * Render tree items recursively (with search filter)
231
+ * @param {Array} items - Array of file/folder objects
232
+ * @param {number} depth - Current depth level
233
+ * @returns {string} HTML string
234
+ */
235
+ function renderTreeItems(items, depth = 0) {
236
+ if (!items || items.length === 0) return '';
237
+
238
+ let html = '<ul class="pb-tree-list">';
239
+
240
+ for (const item of items) {
241
+ // Apply search filter
242
+ if (browserState.searchQuery) {
243
+ if (!item.name.toLowerCase().includes(browserState.searchQuery)) {
244
+ continue;
245
+ }
246
+ }
247
+
248
+ const itemId = `item-${Math.random().toString(36).substr(2, 9)}`;
249
+ const isExpanded = browserState.expandedFolders.has(itemId);
250
+ const filePath = item.path || '';
251
+ const icon = getFileIcon(item.type);
252
+ const badge = getBadge(item.category);
253
+
254
+ html += `<li class="pb-tree-item" data-depth="${depth}">`;
255
+
256
+ if (item.type === 'folder' && item.children && item.children.length > 0) {
257
+ // Folder with children
258
+ html += `
259
+ <div class="pb-item-row" data-tree-item="${itemId}" data-item-type="folder" data-file-path="${filePath}">
260
+ <span class="pb-toggle ${isExpanded ? 'expanded' : ''}">▶</span>
261
+ <span class="pb-icon">${icon}</span>
262
+ <span class="pb-label">${escapeHtml(item.name)}</span>
263
+ ${item.children ? `<span class="pb-count">${item.children.length}</span>` : ''}
264
+ </div>
265
+ `;
266
+
267
+ if (isExpanded) {
268
+ html += `<div class="pb-children">${renderTreeItems(item.children, depth + 1)}</div>`;
269
+ }
270
+ } else if (item.type === 'folder') {
271
+ // Empty folder
272
+ html += `
273
+ <div class="pb-item-row" data-tree-item="${itemId}" data-item-type="folder" data-file-path="${filePath}">
274
+ <span class="pb-toggle disabled">▶</span>
275
+ <span class="pb-icon">${icon}</span>
276
+ <span class="pb-label">${escapeHtml(item.name)}</span>
277
+ </div>
278
+ `;
279
+ } else {
280
+ // File (part, assembly, drawing, etc.)
281
+ html += `
282
+ <div class="pb-item-row" data-tree-item="${itemId}" data-item-type="${item.type}" data-file-path="${filePath}">
283
+ <span class="pb-toggle disabled">•</span>
284
+ <span class="pb-icon">${icon}</span>
285
+ <span class="pb-label">${escapeHtml(item.name)}</span>
286
+ ${badge ? `<span class="pb-badge pb-badge-${badge.color}">${badge.text}</span>` : ''}
287
+ </div>
288
+ `;
289
+ }
290
+
291
+ html += '</li>';
292
+ }
293
+
294
+ html += '</ul>';
295
+ return html;
296
+ }
297
+
298
+ /**
299
+ * Get icon emoji for file type
300
+ * @param {string} type - File type
301
+ * @returns {string} Icon emoji
302
+ */
303
+ function getFileIcon(type) {
304
+ const icons = {
305
+ ipt: '📦',
306
+ iam: '🏗️',
307
+ idw: '📐',
308
+ folder: '📁',
309
+ ipj: '📋',
310
+ };
311
+ return icons[type] || '📄';
312
+ }
313
+
314
+ /**
315
+ * Get category badge info
316
+ * @param {string} category - Category name (CUSTOM, STD, VENDOR)
317
+ * @returns {Object|null} Badge object or null
318
+ */
319
+ function getBadge(category) {
320
+ const badges = {
321
+ CUSTOM: { text: '[CUSTOM]', color: 'green' },
322
+ STD: { text: '[STD]', color: 'blue' },
323
+ VENDOR: { text: '[VENDOR]', color: 'yellow' },
324
+ };
325
+ return badges[category] || null;
326
+ }
327
+
328
+ /**
329
+ * Navigate to a path in the project tree
330
+ * @param {Object} root - Root project object
331
+ * @param {Array} pathArray - Path components
332
+ * @returns {Object} Folder object at path
333
+ */
334
+ function navigateToPath(root, pathArray) {
335
+ let current = root;
336
+ for (const component of pathArray) {
337
+ if (!current.children) return null;
338
+ const next = current.children.find(item => item.name === component);
339
+ if (!next) return null;
340
+ current = next;
341
+ }
342
+ return current;
343
+ }
344
+
345
+ /**
346
+ * Update stats display
347
+ */
348
+ function updateStats() {
349
+ if (!browserState.projectData) return;
350
+
351
+ const stats = countFileTypes(browserState.projectData);
352
+
353
+ const filesEl = document.getElementById('stat-files');
354
+ const partsEl = document.getElementById('stat-parts');
355
+ const asmsEl = document.getElementById('stat-asms');
356
+
357
+ if (filesEl) filesEl.textContent = stats.total;
358
+ if (partsEl) partsEl.textContent = stats.ipt;
359
+ if (asmsEl) asmsEl.textContent = stats.iam;
360
+ }
361
+
362
+ /**
363
+ * Count file types recursively
364
+ * @param {Object} item - Folder or file
365
+ * @returns {Object} Counts by type
366
+ */
367
+ function countFileTypes(item) {
368
+ const counts = { total: 0, ipt: 0, iam: 0, idw: 0 };
369
+
370
+ function traverse(node) {
371
+ if (node.type === 'ipt') counts.ipt++;
372
+ else if (node.type === 'iam') counts.iam++;
373
+ else if (node.type === 'idw') counts.idw++;
374
+
375
+ if (node.type !== 'folder') counts.total++;
376
+
377
+ if (node.children) {
378
+ for (const child of node.children) {
379
+ traverse(child);
380
+ }
381
+ }
382
+ }
383
+
384
+ if (item.children) {
385
+ for (const child of item.children) {
386
+ traverse(child);
387
+ }
388
+ }
389
+
390
+ return counts;
391
+ }
392
+
393
+ /**
394
+ * Dispatch custom event
395
+ * @param {string} eventName - Event name
396
+ * @param {Object} detail - Event detail data
397
+ */
398
+ function dispatchCustomEvent(eventName, detail = {}) {
399
+ window.dispatchEvent(new CustomEvent(eventName, { detail }));
400
+ }
401
+
402
+ /**
403
+ * Escape HTML special characters
404
+ * @param {string} text - Text to escape
405
+ * @returns {string} Escaped text
406
+ */
407
+ function escapeHtml(text) {
408
+ const map = {
409
+ '&': '&amp;',
410
+ '<': '&lt;',
411
+ '>': '&gt;',
412
+ '"': '&quot;',
413
+ "'": '&#039;',
414
+ };
415
+ return text.replace(/[&<>"']/g, char => map[char]);
416
+ }
417
+
418
+ /**
419
+ * Inject CSS styles into the document
420
+ */
421
+ function injectStyles() {
422
+ if (document.getElementById('project-browser-styles')) return;
423
+
424
+ const style = document.createElement('style');
425
+ style.id = 'project-browser-styles';
426
+ style.textContent = `
427
+ .project-browser-overlay {
428
+ display: none;
429
+ position: fixed;
430
+ top: 0;
431
+ left: 0;
432
+ right: 0;
433
+ bottom: 0;
434
+ background: rgba(0, 0, 0, 0.7);
435
+ z-index: 9999;
436
+ align-items: center;
437
+ justify-content: flex-start;
438
+ }
439
+
440
+ .project-browser-panel {
441
+ width: 400px;
442
+ height: 100%;
443
+ background: var(--bg-primary, #1e1e1e);
444
+ border-right: 1px solid var(--border-color, #3e3e42);
445
+ display: flex;
446
+ flex-direction: column;
447
+ box-shadow: 2px 0 8px rgba(0, 0, 0, 0.5);
448
+ }
449
+
450
+ .pb-header {
451
+ padding: 16px;
452
+ border-bottom: 1px solid var(--border-color, #3e3e42);
453
+ display: flex;
454
+ justify-content: space-between;
455
+ align-items: center;
456
+ flex-shrink: 0;
457
+ }
458
+
459
+ .pb-title {
460
+ font-size: 14px;
461
+ font-weight: 600;
462
+ color: var(--text-primary, #e0e0e0);
463
+ }
464
+
465
+ .pb-close-btn {
466
+ background: none;
467
+ border: none;
468
+ color: var(--text-secondary, #a0a0a0);
469
+ font-size: 18px;
470
+ cursor: pointer;
471
+ padding: 4px 8px;
472
+ border-radius: 4px;
473
+ transition: all 0.2s;
474
+ }
475
+
476
+ .pb-close-btn:hover {
477
+ background: var(--bg-secondary, #252526);
478
+ color: var(--text-primary, #e0e0e0);
479
+ }
480
+
481
+ .pb-stats {
482
+ padding: 8px 16px;
483
+ border-bottom: 1px solid var(--border-color, #3e3e42);
484
+ display: flex;
485
+ gap: 16px;
486
+ font-size: 12px;
487
+ color: var(--text-secondary, #a0a0a0);
488
+ flex-shrink: 0;
489
+ }
490
+
491
+ .stat-item {
492
+ display: flex;
493
+ align-items: center;
494
+ gap: 4px;
495
+ }
496
+
497
+ .stat-value {
498
+ color: var(--accent-blue, #58a6ff);
499
+ font-weight: 600;
500
+ }
501
+
502
+ .pb-breadcrumb {
503
+ padding: 8px 12px;
504
+ border-bottom: 1px solid var(--border-color, #3e3e42);
505
+ display: flex;
506
+ align-items: center;
507
+ gap: 4px;
508
+ overflow-x: auto;
509
+ flex-shrink: 0;
510
+ }
511
+
512
+ .breadcrumb-item {
513
+ background: none;
514
+ border: none;
515
+ color: var(--accent-blue, #58a6ff);
516
+ font-size: 12px;
517
+ cursor: pointer;
518
+ padding: 4px 8px;
519
+ border-radius: 3px;
520
+ white-space: nowrap;
521
+ transition: all 0.2s;
522
+ }
523
+
524
+ .breadcrumb-item:hover {
525
+ background: var(--bg-secondary, #252526);
526
+ }
527
+
528
+ .pb-search-box {
529
+ padding: 8px 12px;
530
+ border-bottom: 1px solid var(--border-color, #3e3e42);
531
+ flex-shrink: 0;
532
+ }
533
+
534
+ .pb-search-input {
535
+ width: 100%;
536
+ padding: 8px;
537
+ background: var(--bg-secondary, #252526);
538
+ border: 1px solid var(--border-color, #3e3e42);
539
+ color: var(--text-primary, #e0e0e0);
540
+ font-size: 12px;
541
+ border-radius: 4px;
542
+ outline: none;
543
+ transition: border-color 0.2s;
544
+ }
545
+
546
+ .pb-search-input:focus {
547
+ border-color: var(--accent-blue, #58a6ff);
548
+ }
549
+
550
+ .pb-search-input::placeholder {
551
+ color: var(--text-secondary, #a0a0a0);
552
+ }
553
+
554
+ .pb-tree-container {
555
+ flex: 1;
556
+ overflow-y: auto;
557
+ position: relative;
558
+ min-height: 0;
559
+ }
560
+
561
+ .pb-tree {
562
+ padding: 4px;
563
+ }
564
+
565
+ .pb-tree-list {
566
+ list-style: none;
567
+ margin: 0;
568
+ padding: 0;
569
+ }
570
+
571
+ .pb-tree-item {
572
+ margin: 0;
573
+ padding: 0;
574
+ }
575
+
576
+ .pb-item-row {
577
+ display: flex;
578
+ align-items: center;
579
+ gap: 6px;
580
+ padding: 4px 8px;
581
+ border-radius: 3px;
582
+ cursor: pointer;
583
+ transition: all 0.15s;
584
+ user-select: none;
585
+ font-size: 12px;
586
+ color: var(--text-primary, #e0e0e0);
587
+ }
588
+
589
+ .pb-item-row:hover {
590
+ background: var(--bg-secondary, #252526);
591
+ }
592
+
593
+ .pb-toggle {
594
+ width: 16px;
595
+ display: flex;
596
+ align-items: center;
597
+ justify-content: center;
598
+ font-size: 10px;
599
+ color: var(--text-secondary, #a0a0a0);
600
+ transition: transform 0.2s;
601
+ }
602
+
603
+ .pb-toggle.expanded {
604
+ transform: rotate(90deg);
605
+ }
606
+
607
+ .pb-toggle.disabled {
608
+ cursor: default;
609
+ opacity: 0;
610
+ }
611
+
612
+ .pb-icon {
613
+ font-size: 14px;
614
+ min-width: 14px;
615
+ }
616
+
617
+ .pb-label {
618
+ flex: 1;
619
+ overflow: hidden;
620
+ text-overflow: ellipsis;
621
+ white-space: nowrap;
622
+ }
623
+
624
+ .pb-count {
625
+ font-size: 11px;
626
+ color: var(--text-secondary, #a0a0a0);
627
+ background: var(--bg-tertiary, #2d2d30);
628
+ padding: 2px 6px;
629
+ border-radius: 2px;
630
+ }
631
+
632
+ .pb-badge {
633
+ font-size: 10px;
634
+ font-weight: 600;
635
+ padding: 2px 6px;
636
+ border-radius: 2px;
637
+ white-space: nowrap;
638
+ }
639
+
640
+ .pb-badge-green {
641
+ background: rgba(63, 185, 80, 0.2);
642
+ color: var(--accent-green, #3fb950);
643
+ }
644
+
645
+ .pb-badge-blue {
646
+ background: rgba(88, 166, 255, 0.2);
647
+ color: var(--accent-blue, #58a6ff);
648
+ }
649
+
650
+ .pb-badge-yellow {
651
+ background: rgba(210, 153, 34, 0.2);
652
+ color: var(--accent-yellow, #d29922);
653
+ }
654
+
655
+ .pb-children {
656
+ margin-left: 8px;
657
+ }
658
+
659
+ .pb-empty {
660
+ display: flex;
661
+ flex-direction: column;
662
+ align-items: center;
663
+ justify-content: center;
664
+ height: 100%;
665
+ color: var(--text-secondary, #a0a0a0);
666
+ font-size: 12px;
667
+ text-align: center;
668
+ gap: 8px;
669
+ padding: 24px;
670
+ }
671
+
672
+ .pb-empty p {
673
+ margin: 0;
674
+ }
675
+
676
+ .pb-empty-hint {
677
+ font-size: 11px;
678
+ color: var(--text-secondary, #a0a0a0);
679
+ opacity: 0.7;
680
+ }
681
+
682
+ .pb-footer {
683
+ padding: 12px 16px;
684
+ border-top: 1px solid var(--border-color, #3e3e42);
685
+ display: flex;
686
+ gap: 8px;
687
+ flex-shrink: 0;
688
+ }
689
+
690
+ .pb-btn {
691
+ flex: 1;
692
+ padding: 8px 12px;
693
+ border: none;
694
+ border-radius: 4px;
695
+ font-size: 12px;
696
+ font-weight: 500;
697
+ cursor: pointer;
698
+ transition: all 0.2s;
699
+ white-space: nowrap;
700
+ }
701
+
702
+ .pb-btn-primary {
703
+ background: var(--accent-blue, #58a6ff);
704
+ color: var(--bg-primary, #1e1e1e);
705
+ }
706
+
707
+ .pb-btn-primary:hover {
708
+ background: #4a96e8;
709
+ transform: translateY(-1px);
710
+ box-shadow: 0 2px 8px rgba(88, 166, 255, 0.2);
711
+ }
712
+
713
+ /* Scrollbar styling */
714
+ .pb-tree-container::-webkit-scrollbar {
715
+ width: 8px;
716
+ }
717
+
718
+ .pb-tree-container::-webkit-scrollbar-track {
719
+ background: var(--bg-primary, #1e1e1e);
720
+ }
721
+
722
+ .pb-tree-container::-webkit-scrollbar-thumb {
723
+ background: var(--bg-tertiary, #2d2d30);
724
+ border-radius: 4px;
725
+ }
726
+
727
+ .pb-tree-container::-webkit-scrollbar-thumb:hover {
728
+ background: var(--border-color, #3e3e42);
729
+ }
730
+ `;
731
+
732
+ document.head.appendChild(style);
733
+ }
734
+
735
+ export default {
736
+ initProjectBrowser,
737
+ showBrowser,
738
+ hideBrowser,
739
+ setProject,
740
+ onFileSelect,
741
+ };