senangwebs-tour 1.0.2

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,556 @@
1
+ // Preview Controller - Manages A-Frame preview integration using SWT library
2
+ import { showToast } from "./utils.js";
3
+
4
+ class PreviewController {
5
+ constructor(editor) {
6
+ this.editor = editor;
7
+ this.tour = null;
8
+ this.isInitialized = false;
9
+ this.previewContainer = null;
10
+ this.hasLoadedScene = false; // Track if we've ever loaded a scene
11
+ }
12
+
13
+ /**
14
+ * Initialize A-Frame preview
15
+ */
16
+ async init() {
17
+ this.previewContainer = document.getElementById("preview");
18
+ if (!this.previewContainer) {
19
+ console.error("Preview element not found");
20
+ return false;
21
+ }
22
+
23
+ // Wait for A-Frame to be loaded
24
+ if (typeof AFRAME === "undefined") {
25
+ await this.waitForLibrary("AFRAME", 5000);
26
+ }
27
+
28
+ // Wait for SWT library to be loaded
29
+ if (typeof SWT === "undefined") {
30
+ await this.waitForLibrary("SWT", 5000);
31
+ }
32
+ this.isInitialized = true;
33
+ return true;
34
+ }
35
+
36
+ /**
37
+ * Wait for a global library to be available
38
+ */
39
+ async waitForLibrary(libraryName, timeout = 5000) {
40
+ const startTime = Date.now();
41
+
42
+ while (typeof window[libraryName] === "undefined") {
43
+ if (Date.now() - startTime > timeout) {
44
+ throw new Error(`Timeout waiting for ${libraryName} to load`);
45
+ }
46
+ await new Promise((resolve) => setTimeout(resolve, 100));
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Load scene into preview using SWT library
52
+ */
53
+ async loadScene(scene, preserveCameraRotation = true) {
54
+ if (!this.isInitialized || !scene) {
55
+ return;
56
+ }
57
+
58
+ // Validate scene has required data
59
+ if (!scene.imageUrl || !scene.id) {
60
+ console.error("Invalid scene data:", scene);
61
+ return;
62
+ }
63
+
64
+ // Show loading animation
65
+ this.showLoading();
66
+
67
+ // Save camera rotation before destroying scene
68
+ let savedRotation = null;
69
+ if (preserveCameraRotation && this.tour) {
70
+ savedRotation = this.getCameraRotation();
71
+ }
72
+
73
+ // Destroy existing tour if any
74
+ if (this.tour) {
75
+ try {
76
+ this.tour.destroy();
77
+ } catch (error) {
78
+ console.error("Error destroying tour:", error);
79
+ }
80
+ this.tour = null;
81
+ }
82
+
83
+ // Clear preview container carefully
84
+ // Only do complex cleanup if we've actually loaded a scene before
85
+ if (this.hasLoadedScene) {
86
+ const existingScene = this.previewContainer.querySelector("a-scene");
87
+ if (existingScene) {
88
+ try {
89
+ // Remove the scene element - A-Frame will handle cleanup if it's ready
90
+ this.previewContainer.removeChild(existingScene);
91
+ } catch (error) {
92
+ console.error("Error removing scene:", error);
93
+ }
94
+ }
95
+
96
+ // Clear any remaining children (loading overlays, empty state, etc)
97
+ while (this.previewContainer.firstChild) {
98
+ this.previewContainer.removeChild(this.previewContainer.firstChild);
99
+ }
100
+ } else {
101
+ // First load - only remove non-A-Frame elements (like empty state divs)
102
+ const children = Array.from(this.previewContainer.children);
103
+ children.forEach(child => {
104
+ // Only remove if it's NOT an a-scene (shouldn't be any, but be safe)
105
+ if (child.tagName.toLowerCase() !== 'a-scene') {
106
+ this.previewContainer.removeChild(child);
107
+ }
108
+ });
109
+ }
110
+
111
+ // Create loading overlay (will be removed after scene loads)
112
+ const loadingOverlay = this.createLoadingOverlay();
113
+ this.previewContainer.appendChild(loadingOverlay);
114
+
115
+ // Create A-Frame scene
116
+ const aframeScene = document.createElement("a-scene");
117
+ aframeScene.id = "preview-scene";
118
+ aframeScene.setAttribute("embedded", "");
119
+ aframeScene.setAttribute("vr-mode-ui", "enabled: false;");
120
+ this.previewContainer.appendChild(aframeScene);
121
+
122
+ // Give A-Frame a moment to start initializing before we proceed
123
+ await new Promise((resolve) => setTimeout(resolve, 100));
124
+
125
+ // Build tour config for this single scene
126
+ // Transform editor scene format to library format
127
+ const transformedHotspots = (scene.hotspots || []).map((h) => ({
128
+ id: h.id,
129
+ position: h.position,
130
+ action: {
131
+ type: h.type === "navigation" ? "navigateTo" : h.type,
132
+ target: h.targetSceneId,
133
+ },
134
+ appearance: {
135
+ color: h.color || "#00ff00",
136
+ icon: h.icon || null,
137
+ scale: h.scale || "1 1 1",
138
+ },
139
+ tooltip: {
140
+ text: h.title || "Hotspot",
141
+ },
142
+ }));
143
+
144
+ const libraryScene = {
145
+ id: scene.id,
146
+ name: scene.name,
147
+ panorama: scene.imageUrl, // Editor uses 'imageUrl', library expects 'panorama'
148
+ hotspots: transformedHotspots,
149
+ };
150
+
151
+ // Build scenes object with ALL scenes (for navigation to work)
152
+ const allScenes = {};
153
+ const editorScenes = this.editor.sceneManager.scenes || [];
154
+ editorScenes.forEach((s) => {
155
+ const sceneHotspots = (s.hotspots || []).map((h) => ({
156
+ id: h.id,
157
+ position: h.position,
158
+ action: {
159
+ type: h.type === "navigation" ? "navigateTo" : h.type,
160
+ target: h.targetSceneId,
161
+ },
162
+ appearance: {
163
+ color: h.color || "#00ff00",
164
+ icon: h.icon || null,
165
+ scale: h.scale || "1 1 1",
166
+ },
167
+ tooltip: {
168
+ text: h.title || "Hotspot",
169
+ },
170
+ }));
171
+
172
+ allScenes[s.id] = {
173
+ id: s.id,
174
+ name: s.name,
175
+ panorama: s.imageUrl,
176
+ hotspots: sceneHotspots,
177
+ };
178
+ });
179
+
180
+ const tourConfig = {
181
+ title: scene.name,
182
+ initialScene: scene.id,
183
+ scenes: allScenes,
184
+ settings: {
185
+ autoRotate: false,
186
+ showCompass: false,
187
+ },
188
+ };
189
+
190
+ try {
191
+ // Create new tour instance
192
+ this.tour = new SWT.Tour(aframeScene, tourConfig);
193
+
194
+ // Set up event listeners
195
+ this.tour.addEventListener("tour-started", (e) => {});
196
+
197
+ this.tour.addEventListener("scene-loaded", (e) => {});
198
+
199
+ this.tour.addEventListener("hotspot-activated", (e) => {
200
+ // Find the hotspot index by ID and select it
201
+ const hotspotId = e.detail?.hotspotId;
202
+ if (hotspotId) {
203
+ const scene = this.editor.sceneManager.getCurrentScene();
204
+ if (scene) {
205
+ const hotspotIndex = scene.hotspots.findIndex(
206
+ (h) => h.id === hotspotId
207
+ );
208
+ if (hotspotIndex >= 0) {
209
+ this.editor.selectHotspot(hotspotIndex);
210
+ }
211
+ }
212
+ }
213
+ });
214
+
215
+ // Start the tour
216
+ await this.tour.start();
217
+
218
+ // Mark that we've successfully loaded a scene
219
+ this.hasLoadedScene = true;
220
+
221
+ // Hide loading animation after scene loads
222
+ this.hideLoading();
223
+
224
+ // Restore camera rotation if preserved
225
+ if (savedRotation && preserveCameraRotation) {
226
+ this.setCameraRotation(savedRotation);
227
+ }
228
+
229
+ // Setup click handler after a short delay to ensure A-Frame is ready
230
+ setTimeout(() => {
231
+ this.setupClickHandler();
232
+ }, 500);
233
+ } catch (error) {
234
+ console.error("Failed to load preview:", error);
235
+ showToast("Failed to load preview: " + error.message, "error");
236
+ // Hide loading on error
237
+ this.hideLoading();
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Setup click handler for hotspot placement
243
+ */
244
+ setupClickHandler() {
245
+ if (!this.tour) {
246
+ return;
247
+ }
248
+
249
+ const aframeScene = this.previewContainer.querySelector("a-scene");
250
+ if (!aframeScene) {
251
+ setTimeout(() => this.setupClickHandler(), 200); // Retry
252
+ return;
253
+ }
254
+
255
+ // Remove any existing click handler to avoid duplicates
256
+ if (this.clickHandler) {
257
+ aframeScene.removeEventListener("click", this.clickHandler);
258
+ }
259
+
260
+ // Create and store the click handler
261
+ this.clickHandler = (evt) => {
262
+ if (!this.editor.hotspotEditor.placementMode) {
263
+ return;
264
+ }
265
+
266
+ // Try to get intersection from event detail first
267
+ let intersection = evt.detail?.intersection;
268
+
269
+ // If no intersection, perform manual raycasting
270
+ if (!intersection) {
271
+ const camera = aframeScene.querySelector("[camera]");
272
+ const sky = aframeScene.querySelector("a-sky");
273
+
274
+ if (!camera || !sky) {
275
+ showToast("Scene not ready, please try again", "warning");
276
+ return;
277
+ }
278
+
279
+ // Get mouse position relative to canvas
280
+ const canvas = aframeScene.canvas;
281
+ const rect = canvas.getBoundingClientRect();
282
+ const mouse = {
283
+ x: ((evt.clientX - rect.left) / rect.width) * 2 - 1,
284
+ y: -((evt.clientY - rect.top) / rect.height) * 2 + 1,
285
+ };
286
+
287
+ // Perform raycasting
288
+ const raycaster = new THREE.Raycaster();
289
+ const cameraEl = camera.object3D;
290
+ raycaster.setFromCamera(mouse, cameraEl.children[0]); // Get the actual camera
291
+
292
+ // Raycast against the sky sphere
293
+ const intersects = raycaster.intersectObject(sky.object3D, true);
294
+
295
+ if (intersects.length > 0) {
296
+ intersection = intersects[0];
297
+ } else {
298
+ showToast("Click on the panorama image", "warning");
299
+ return;
300
+ }
301
+ }
302
+
303
+ const point = intersection.point;
304
+ const position = {
305
+ x: parseFloat(point.x.toFixed(2)),
306
+ y: parseFloat(point.y.toFixed(2)),
307
+ z: parseFloat(point.z.toFixed(2)),
308
+ };
309
+ this.editor.addHotspotAtPosition(position);
310
+ };
311
+ aframeScene.addEventListener("click", this.clickHandler);
312
+ }
313
+
314
+ /**
315
+ * Get current camera rotation
316
+ */
317
+ getCameraRotation() {
318
+ const aframeScene = this.previewContainer?.querySelector("a-scene");
319
+ if (!aframeScene) {
320
+ return null;
321
+ }
322
+
323
+ const camera = aframeScene.querySelector("[camera]");
324
+ if (!camera) {
325
+ return null;
326
+ }
327
+
328
+ // Get rotation from object3D which is more reliable
329
+ const rotation = camera.object3D.rotation;
330
+ const savedRotation = {
331
+ x: rotation.x,
332
+ y: rotation.y,
333
+ z: rotation.z,
334
+ };
335
+ return savedRotation;
336
+ }
337
+
338
+ /**
339
+ * Set camera rotation
340
+ */
341
+ setCameraRotation(rotation) {
342
+ if (!rotation) {
343
+ return;
344
+ }
345
+
346
+ const aframeScene = this.previewContainer?.querySelector("a-scene");
347
+ if (!aframeScene) {
348
+ return;
349
+ }
350
+
351
+ const camera = aframeScene.querySelector("[camera]");
352
+ if (!camera) {
353
+ return;
354
+ }
355
+
356
+ // Set rotation on object3D directly
357
+ const setRotation = () => {
358
+ if (camera.object3D) {
359
+ camera.object3D.rotation.set(rotation.x, rotation.y, rotation.z);
360
+ }
361
+ };
362
+
363
+ // Try immediately and also after a delay to ensure it sticks
364
+ setRotation();
365
+ setTimeout(setRotation, 100);
366
+ setTimeout(setRotation, 300);
367
+ }
368
+
369
+ /**
370
+ * Refresh preview (reload current scene while preserving camera rotation)
371
+ */
372
+ async refresh() {
373
+ const scene = this.editor.sceneManager.getCurrentScene();
374
+ if (scene) {
375
+ // Save current camera rotation
376
+ const savedRotation = this.getCameraRotation();
377
+ // Reload scene
378
+ await this.loadScene(scene);
379
+
380
+ // Restore camera rotation
381
+ if (savedRotation) {
382
+ this.setCameraRotation(savedRotation);
383
+ }
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Reset camera
389
+ */
390
+ resetCamera() {
391
+ const camera = this.previewContainer?.querySelector("[camera]");
392
+ if (camera) {
393
+ camera.setAttribute("rotation", "0 0 0");
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Point camera to hotspot position
399
+ */
400
+ pointCameraToHotspot(hotspotPosition) {
401
+ if (!hotspotPosition) {
402
+ return;
403
+ }
404
+
405
+ const aframeScene = this.previewContainer?.querySelector("a-scene");
406
+ if (!aframeScene) {
407
+ return;
408
+ }
409
+
410
+ const camera = aframeScene.querySelector("[camera]");
411
+ if (!camera || !camera.object3D) {
412
+ return;
413
+ }
414
+
415
+ // Get camera position (usually at origin 0,0,0)
416
+ const cameraPos = camera.object3D.position;
417
+
418
+ // Calculate direction vector from camera to hotspot
419
+ const direction = new THREE.Vector3(
420
+ hotspotPosition.x - cameraPos.x,
421
+ hotspotPosition.y - cameraPos.y,
422
+ hotspotPosition.z - cameraPos.z
423
+ );
424
+
425
+ // Calculate spherical coordinates (yaw and pitch)
426
+ const distance = direction.length();
427
+
428
+ // Pitch (up/down rotation around X-axis) - in degrees
429
+ const pitch = Math.asin(direction.y / distance) * (180 / Math.PI);
430
+
431
+ // Yaw (left/right rotation around Y-axis) - in degrees
432
+ // Using atan2 to get correct quadrant
433
+ const yaw = Math.atan2(direction.x, direction.z) * (180 / Math.PI);
434
+
435
+ // Apply smooth rotation with animation
436
+ this.animateCameraRotation(camera, { x: pitch, y: yaw, z: 0 });
437
+ }
438
+
439
+ /**
440
+ * Animate camera rotation smoothly
441
+ */
442
+ animateCameraRotation(camera, targetRotation, duration = 800) {
443
+ if (!camera || !camera.object3D) return;
444
+
445
+ const startRotation = {
446
+ x: camera.object3D.rotation.x * (180 / Math.PI),
447
+ y: camera.object3D.rotation.y * (180 / Math.PI),
448
+ z: camera.object3D.rotation.z * (180 / Math.PI),
449
+ };
450
+
451
+ // Handle angle wrapping for smooth rotation
452
+ let deltaY = targetRotation.y - startRotation.y;
453
+
454
+ // Normalize to -180 to 180 range
455
+ while (deltaY > 180) deltaY -= 360;
456
+ while (deltaY < -180) deltaY += 360;
457
+
458
+ const endRotationY = startRotation.y + deltaY;
459
+
460
+ const startTime = Date.now();
461
+
462
+ const animate = () => {
463
+ const elapsed = Date.now() - startTime;
464
+ const progress = Math.min(elapsed / duration, 1);
465
+
466
+ // Ease-in-out function for smooth animation
467
+ const eased =
468
+ progress < 0.5
469
+ ? 2 * progress * progress
470
+ : 1 - Math.pow(-2 * progress + 2, 2) / 2;
471
+
472
+ // Interpolate rotation
473
+ const currentRotation = {
474
+ x: startRotation.x + (targetRotation.x - startRotation.x) * eased,
475
+ y: startRotation.y + (endRotationY - startRotation.y) * eased,
476
+ z: startRotation.z + (targetRotation.z - startRotation.z) * eased,
477
+ };
478
+
479
+ // Apply rotation (convert degrees to radians)
480
+ camera.object3D.rotation.set(
481
+ currentRotation.x * (Math.PI / 180),
482
+ currentRotation.y * (Math.PI / 180),
483
+ currentRotation.z * (Math.PI / 180)
484
+ );
485
+
486
+ if (progress < 1) {
487
+ requestAnimationFrame(animate);
488
+ }
489
+ };
490
+
491
+ animate();
492
+ }
493
+
494
+ /**
495
+ * Highlight hotspot (not needed with library, but keep for compatibility)
496
+ */
497
+ highlightHotspot(index) {
498
+ // The library handles hotspot visualization
499
+ // This is kept for API compatibility
500
+ }
501
+
502
+ /**
503
+ * Update hotspot marker (refresh scene while preserving camera rotation)
504
+ */
505
+ async updateHotspotMarker(index) {
506
+ const scene = this.editor.sceneManager.getCurrentScene();
507
+ if (!scene || !this.tour) return;
508
+
509
+ const hotspot = scene.hotspots[index];
510
+ if (!hotspot) return;
511
+
512
+ // Refresh the preview to reflect changes, camera rotation will be preserved
513
+ await this.refresh();
514
+ }
515
+
516
+ /**
517
+ * Create loading overlay element
518
+ */
519
+ createLoadingOverlay() {
520
+ const overlay = document.createElement("div");
521
+ overlay.className = "preview-loading";
522
+ overlay.innerHTML = `
523
+ <div class="loading-spinner"></div>
524
+ <div class="loading-text">Loading scene...</div>
525
+ `;
526
+ return overlay;
527
+ }
528
+
529
+ /**
530
+ * Show loading animation
531
+ */
532
+ showLoading() {
533
+ const existing = this.previewContainer?.querySelector(".preview-loading");
534
+ if (existing) {
535
+ existing.classList.remove("hidden");
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Hide loading animation
541
+ */
542
+ hideLoading() {
543
+ const loading = this.previewContainer?.querySelector(".preview-loading");
544
+ if (loading) {
545
+ loading.classList.add("hidden");
546
+ // Remove after transition
547
+ setTimeout(() => {
548
+ if (loading.parentNode) {
549
+ loading.parentNode.removeChild(loading);
550
+ }
551
+ }, 300);
552
+ }
553
+ }
554
+ }
555
+
556
+ export default PreviewController;