kishare 1.0.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.
@@ -0,0 +1,546 @@
1
+ /**
2
+ * Viewer panel component - wraps KiCanvas embed element
3
+ */
4
+
5
+ import type { ProjectMetadata, GitInfo } from '../lib/project-index.js';
6
+ import { router, type ViewPosition, type MarkerBounds } from '../lib/router.js';
7
+ import { MARKER, TOAST } from '../lib/constants.js';
8
+ import { githubIcon } from '../lib/html-utils.js';
9
+
10
+ interface MousePosition {
11
+ x: number;
12
+ y: number;
13
+ }
14
+
15
+ export class ViewerPanel extends HTMLElement {
16
+ private currentEmbed: HTMLElement | null = null;
17
+ private mousePosition: MousePosition = { x: 0, y: 0 };
18
+ private gitInfo: GitInfo | null = null;
19
+ private markerId: number | null = null;
20
+
21
+ constructor() {
22
+ super();
23
+ }
24
+
25
+ connectedCallback() {
26
+ this.render();
27
+ this.setupEventListeners();
28
+ }
29
+
30
+ /**
31
+ * Set git info for GitHub issue links
32
+ */
33
+ setGitInfo(git: GitInfo) {
34
+ this.gitInfo = git;
35
+ }
36
+
37
+ /**
38
+ * Load a project in the viewer
39
+ */
40
+ loadProject(project: ProjectMetadata, position?: ViewPosition, marker?: import('../lib/router.js').MarkerBounds) {
41
+ // Cleanup previous marker
42
+ this.clearMarker();
43
+
44
+ // Clear existing content
45
+ this.innerHTML = '';
46
+
47
+ // Create container for viewer and toolbar
48
+ const container = document.createElement('div');
49
+ container.className = 'viewer-container';
50
+
51
+ // Create kicanvas-embed element
52
+ const embed = document.createElement('kicanvas-embed');
53
+ embed.setAttribute('controls', 'full');
54
+
55
+ // Add project file as a source
56
+ if (project.projectFile) {
57
+ this.addSource(embed, project.projectFile);
58
+ }
59
+
60
+ // Add all schematic files
61
+ for (const schematic of project.schematics) {
62
+ this.addSource(embed, schematic.path);
63
+ }
64
+
65
+ // Add PCB if exists
66
+ if (project.pcb) {
67
+ this.addSource(embed, project.pcb);
68
+ }
69
+
70
+ container.appendChild(embed);
71
+
72
+ this.appendChild(container);
73
+ this.currentEmbed = embed;
74
+
75
+ // Recreate context menu (it was cleared with innerHTML)
76
+ this.createContextMenu();
77
+
78
+ // Setup event listeners
79
+ this.setupViewerEvents(embed, position, marker);
80
+ }
81
+
82
+ /**
83
+ * Setup event listeners
84
+ */
85
+ private setupViewerEvents(embed: HTMLElement, position?: ViewPosition, marker?: import('../lib/router.js').MarkerBounds) {
86
+ // Track mouse position
87
+ embed.addEventListener('kicanvas:mousemove', ((e: CustomEvent) => {
88
+ this.mousePosition = {
89
+ x: e.detail.x,
90
+ y: e.detail.y,
91
+ };
92
+ }) as EventListener);
93
+
94
+ // Pan to position after load
95
+ if (position) {
96
+ embed.addEventListener('kicanvas:load', () => {
97
+ this.panToPosition(position, marker);
98
+ }, { once: true });
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Pan viewer to a specific position and optionally show marker
104
+ */
105
+ private panToPosition(position: ViewPosition, marker?: import('../lib/router.js').MarkerBounds) {
106
+ try {
107
+ // Switch to the specified file if needed
108
+ if (position.file && position.file !== 'pcb') {
109
+ // Schematic sheet - need to switch to it first
110
+ const shadowRoot = this.currentEmbed?.shadowRoot;
111
+ if (shadowRoot) {
112
+ const schematicApp = shadowRoot.querySelector('kc-schematic-app');
113
+ if (schematicApp && (schematicApp as any).project) {
114
+ const project = (schematicApp as any).project;
115
+ console.log('Switching to sheet:', position.file, 'current:', project.active_page?.project_path);
116
+
117
+ // Always set the active page to ensure we're on the right sheet
118
+ project.set_active_page(position.file);
119
+
120
+ // Wait for the sheet to load and viewer to be ready
121
+ setTimeout(() => this.doPanAndMarker(position, marker, 'schematic'), 300);
122
+ return;
123
+ }
124
+ }
125
+ }
126
+
127
+ // PCB or no file specified - use currently active viewer
128
+ this.doPanAndMarker(position, marker, position.file === 'pcb' ? 'pcb' : null);
129
+ } catch (e) {
130
+ console.warn('Failed to pan to position:', e);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Actually perform the pan and add marker
136
+ */
137
+ private doPanAndMarker(position: ViewPosition, marker?: import('../lib/router.js').MarkerBounds, preferredType?: 'schematic' | 'pcb' | null) {
138
+ const viewer = this.getViewer(preferredType);
139
+ if (!viewer) {
140
+ console.warn('No viewer found for type:', preferredType);
141
+ return;
142
+ }
143
+
144
+ try {
145
+ console.log('Adding marker to viewer at', position.x, position.y);
146
+ viewer.viewport.camera.center.set(position.x, position.y);
147
+ viewer.viewport.camera.zoom = position.zoom;
148
+ viewer.draw();
149
+
150
+ // Add marker if specified
151
+ if (marker) {
152
+ // Draw bounding box with arrow pointing to center
153
+ const centerX = marker.x + marker.width / 2;
154
+ const centerY = marker.y + marker.height / 2;
155
+
156
+ console.log('Adding marker at center:', centerX, centerY);
157
+ // Add arrow marker at center of bounding box
158
+ this.markerId = viewer.addMarker(centerX, centerY, MARKER.STYLE);
159
+ } else {
160
+ // No marker specified, just add a simple arrow at the center
161
+ this.markerId = viewer.addMarker(position.x, position.y, MARKER.STYLE);
162
+ }
163
+ } catch (e) {
164
+ console.warn('Failed to do pan and marker:', e);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Clear any active marker
170
+ */
171
+ private clearMarker() {
172
+ if (this.markerId !== null) {
173
+ const viewer = this.getViewer();
174
+ if (viewer) {
175
+ viewer.removeMarker(this.markerId);
176
+ }
177
+ this.markerId = null;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Toggle visibility of all markers
183
+ */
184
+ private toggleMarkersVisible() {
185
+ const viewer = this.getViewer();
186
+ if (!viewer) return;
187
+
188
+ try {
189
+ const visible = viewer.toggleMarkersVisible();
190
+ this.showToast(visible ? 'Markers shown' : 'Markers hidden');
191
+ } catch (e) {
192
+ console.warn('Failed to toggle markers:', e);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Get the current viewer instance
198
+ */
199
+ private getViewer(preferredType?: 'schematic' | 'pcb' | null): any {
200
+ if (!this.currentEmbed) return null;
201
+
202
+ const shadowRoot = this.currentEmbed.shadowRoot;
203
+ if (!shadowRoot) return null;
204
+
205
+ // If a specific type is requested, return that viewer
206
+ if (preferredType === 'pcb') {
207
+ const boardApp = shadowRoot.querySelector('kc-board-app');
208
+ if (boardApp && (boardApp as any).viewer) {
209
+ return (boardApp as any).viewer;
210
+ }
211
+ return null;
212
+ }
213
+
214
+ if (preferredType === 'schematic') {
215
+ const schematicApp = shadowRoot.querySelector('kc-schematic-app');
216
+ if (schematicApp && (schematicApp as any).viewer) {
217
+ return (schematicApp as any).viewer;
218
+ }
219
+ return null;
220
+ }
221
+
222
+ // No preference - check which is visible
223
+ // Try board viewer first, then schematic
224
+ const boardApp = shadowRoot.querySelector('kc-board-app');
225
+ if (boardApp) {
226
+ const style = window.getComputedStyle(boardApp);
227
+ if (style.display !== 'none' && (boardApp as any).viewer) {
228
+ return (boardApp as any).viewer;
229
+ }
230
+ }
231
+
232
+ const schematicApp = shadowRoot.querySelector('kc-schematic-app');
233
+ if (schematicApp) {
234
+ const style = window.getComputedStyle(schematicApp);
235
+ if (style.display !== 'none' && (schematicApp as any).viewer) {
236
+ return (schematicApp as any).viewer;
237
+ }
238
+ }
239
+
240
+ return null;
241
+ }
242
+
243
+ /**
244
+ * Get the current file identifier (for URL tracking)
245
+ */
246
+ private getCurrentFile(): string | null {
247
+ if (!this.currentEmbed) return null;
248
+
249
+ const shadowRoot = this.currentEmbed.shadowRoot;
250
+ if (!shadowRoot) return null;
251
+
252
+ // Check if board app is visible and has a viewer
253
+ const boardApp = shadowRoot.querySelector('kc-board-app');
254
+ if (boardApp) {
255
+ const style = window.getComputedStyle(boardApp);
256
+ if (style.display !== 'none' && (boardApp as any).viewer) {
257
+ return 'pcb';
258
+ }
259
+ }
260
+
261
+ // Check if schematic app is visible and has active page
262
+ const schematicApp = shadowRoot.querySelector('kc-schematic-app');
263
+ if (schematicApp) {
264
+ const style = window.getComputedStyle(schematicApp);
265
+ if (style.display !== 'none' && (schematicApp as any).project?.active_page) {
266
+ const activePage = (schematicApp as any).project.active_page;
267
+ console.log('Current active page:', activePage.project_path);
268
+ // Return the project_path which uniquely identifies the sheet
269
+ return activePage.project_path || null;
270
+ }
271
+ }
272
+
273
+ return null;
274
+ }
275
+
276
+ /**
277
+ * Get current view position
278
+ */
279
+ private getCurrentPosition(): ViewPosition | null {
280
+ const viewer = this.getViewer();
281
+ if (!viewer) return null;
282
+
283
+ try {
284
+ const file = this.getCurrentFile();
285
+ return {
286
+ x: viewer.viewport.camera.center.x,
287
+ y: viewer.viewport.camera.center.y,
288
+ zoom: viewer.viewport.camera.zoom,
289
+ file: file || undefined,
290
+ };
291
+ } catch (e) {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Setup event listeners for keyboard shortcuts
298
+ */
299
+ private setupEventListeners() {
300
+ this.createContextMenu();
301
+
302
+ // Use keyboard shortcut instead: 'C' key to open context menu
303
+ let lastMouseX = 0;
304
+ let lastMouseY = 0;
305
+
306
+ // Track screen mouse position
307
+ document.addEventListener('mousemove', (e: MouseEvent) => {
308
+ lastMouseX = e.clientX;
309
+ lastMouseY = e.clientY;
310
+ });
311
+
312
+ document.addEventListener('keydown', (e: KeyboardEvent) => {
313
+ const key = e.key.toLowerCase();
314
+
315
+ // Press 'C' to open context menu at mouse position
316
+ if (key === 'c') {
317
+ if (!this.currentEmbed) return;
318
+ this.showContextMenu(lastMouseX, lastMouseY);
319
+ }
320
+
321
+ // Press 'T' to toggle marker visibility
322
+ if (key === 't') {
323
+ if (!this.currentEmbed) return;
324
+ this.toggleMarkersVisible();
325
+ }
326
+ });
327
+
328
+ document.addEventListener('click', () => {
329
+ this.closeContextMenu();
330
+ });
331
+ }
332
+
333
+ /**
334
+ * Create context menu element
335
+ */
336
+ private createContextMenu() {
337
+ // Remove old menu if exists
338
+ const oldMenu = this.querySelector('.context-menu');
339
+ if (oldMenu) oldMenu.remove();
340
+
341
+ // Create context menu element
342
+ const menu = document.createElement('div');
343
+ menu.className = 'context-menu hidden';
344
+ menu.innerHTML = `
345
+ <button class="context-menu-item" data-action="copy">
346
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
347
+ <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
348
+ </svg>
349
+ Copy location link
350
+ </button>
351
+ <button class="context-menu-item" data-action="github">
352
+ ${githubIcon(16)}
353
+ Open GitHub Issue
354
+ </button>
355
+ `;
356
+ this.appendChild(menu);
357
+
358
+ // Setup menu item click handlers
359
+ menu.querySelector('[data-action="copy"]')!.addEventListener('click', (e) => {
360
+ e.stopPropagation();
361
+ this.copyLocationLink();
362
+ this.closeContextMenu();
363
+ });
364
+
365
+ menu.querySelector('[data-action="github"]')!.addEventListener('click', (e) => {
366
+ e.stopPropagation();
367
+ this.openGitHubIssue();
368
+ this.closeContextMenu();
369
+ });
370
+ }
371
+
372
+ /**
373
+ * Show context menu at position
374
+ */
375
+ private showContextMenu(x: number, y: number) {
376
+ const menu = this.querySelector('.context-menu') as HTMLElement;
377
+ if (!menu) return;
378
+
379
+ // Position menu
380
+ menu.style.left = `${x}px`;
381
+ menu.style.top = `${y}px`;
382
+ menu.classList.remove('hidden');
383
+
384
+ // Adjust if menu goes off screen
385
+ const rect = menu.getBoundingClientRect();
386
+ if (rect.right > window.innerWidth) {
387
+ menu.style.left = `${x - rect.width}px`;
388
+ }
389
+ if (rect.bottom > window.innerHeight) {
390
+ menu.style.top = `${y - rect.height}px`;
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Close context menu
396
+ */
397
+ private closeContextMenu() {
398
+ const menu = this.querySelector('.context-menu');
399
+ menu?.classList.add('hidden');
400
+ }
401
+
402
+ /**
403
+ * Creates marker at current mouse position and returns URL.
404
+ */
405
+ private createLocationMarker(): string {
406
+ const position = this.getCurrentPosition();
407
+ if (!position) {
408
+ console.warn('No viewer position available');
409
+ return;
410
+ }
411
+
412
+ // Get mouse position directly from viewer instead of relying on events
413
+ const viewer = this.getViewer();
414
+ if (!viewer || !viewer.mouse_position) {
415
+ console.warn('No viewer mouse position available');
416
+ return;
417
+ }
418
+
419
+ const mousePos = {
420
+ x: viewer.mouse_position.x,
421
+ y: viewer.mouse_position.y,
422
+ };
423
+ console.log('Mouse position for marker:', mousePos);
424
+
425
+ // Create a marker bounds around current mouse position
426
+ const marker: MarkerBounds = {
427
+ x: mousePos.x - MARKER.SIZE / 2,
428
+ y: mousePos.y - MARKER.SIZE / 2,
429
+ width: MARKER.SIZE,
430
+ height: MARKER.SIZE,
431
+ };
432
+ console.log('Marker bounds:', marker);
433
+
434
+ // Add temporary marker to show what will be shared
435
+ const centerX = marker.x + marker.width / 2;
436
+ const centerY = marker.y + marker.height / 2;
437
+
438
+ const tempMarkerId = viewer.addMarker(centerX, centerY, MARKER.STYLE);
439
+
440
+ // Remove temporary marker after configured duration
441
+ setTimeout(() => {
442
+ viewer.removeMarker(tempMarkerId);
443
+ }, MARKER.TEMP_DURATION);
444
+
445
+ return router.buildPositionUrl(position, marker);
446
+ }
447
+
448
+ /**
449
+ * Copy location link to clipboard
450
+ */
451
+ private async copyLocationLink() {
452
+ const url = this.createLocationMarker();
453
+ try {
454
+ await navigator.clipboard.writeText(url);
455
+ this.showToast('Link copied to clipboard');
456
+ } catch (e) {
457
+ console.error('Failed to copy:', e);
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Open GitHub issue with location link
463
+ */
464
+ private openGitHubIssue() {
465
+ const url = this.createLocationMarker();
466
+ const route = router.getCurrentRoute();
467
+
468
+ // Build GitHub issue URL
469
+ const title = encodeURIComponent(`Review: ${route.projectId || 'Project'}`);
470
+ const body = encodeURIComponent(`## Location\n\n[View in workspace](${url})\n\n## Comment\n\n`);
471
+
472
+ if (this.gitInfo?.repoUrl) {
473
+ const issueUrl = `${this.gitInfo.repoUrl}/issues/new?title=${title}&body=${body}`;
474
+ window.open(issueUrl, '_blank');
475
+ } else {
476
+ // Fallback: just copy the link
477
+ this.copyLocationLink();
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Show a toast notification
483
+ */
484
+ private showToast(message: string) {
485
+ const existing = this.querySelector('.toast');
486
+ if (existing) existing.remove();
487
+
488
+ const toast = document.createElement('div');
489
+ toast.className = 'toast';
490
+ toast.textContent = message;
491
+ this.appendChild(toast);
492
+
493
+ setTimeout(() => toast.classList.add('show'), 10);
494
+ setTimeout(() => {
495
+ toast.classList.remove('show');
496
+ setTimeout(() => toast.remove(), TOAST.FADE_DURATION);
497
+ }, TOAST.DURATION);
498
+ }
499
+
500
+ /**
501
+ * Add a file source to the kicanvas embed
502
+ */
503
+ private addSource(embed: HTMLElement, src: string) {
504
+ const source = document.createElement('kicanvas-source');
505
+ source.setAttribute('src', src);
506
+ embed.appendChild(source);
507
+ }
508
+
509
+ /**
510
+ * Show welcome message when no project is selected
511
+ */
512
+ showWelcome() {
513
+ this.currentEmbed = null;
514
+ this.clearMarker();
515
+ this.innerHTML = `
516
+ <div class="welcome">
517
+ <h2>KiShare Workspace</h2>
518
+ <p>Select a project from the sidebar to view schematics and PCBs</p>
519
+ </div>
520
+ `;
521
+ }
522
+
523
+ /**
524
+ * Show error message
525
+ */
526
+ showError(message: string) {
527
+ this.currentEmbed = null;
528
+ this.clearMarker();
529
+ this.innerHTML = `
530
+ <div class="error">
531
+ <h3>Error</h3>
532
+ <p>${message}</p>
533
+ </div>
534
+ `;
535
+ }
536
+
537
+ /**
538
+ * Initial render
539
+ */
540
+ private render() {
541
+ this.showWelcome();
542
+ }
543
+ }
544
+
545
+ // Register custom element
546
+ customElements.define('viewer-panel', ViewerPanel);