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.
- package/LICENSE.md +21 -0
- package/README.md +653 -0
- package/dist/swt-editor.css +869 -0
- package/dist/swt-editor.css.map +1 -0
- package/dist/swt-editor.js +2853 -0
- package/dist/swt-editor.js.map +1 -0
- package/dist/swt-editor.min.css +1 -0
- package/dist/swt-editor.min.js +1 -0
- package/dist/swt.js +873 -0
- package/dist/swt.js.map +1 -0
- package/dist/swt.min.js +1 -0
- package/package.json +39 -0
- package/src/AssetManager.js +153 -0
- package/src/HotspotManager.js +193 -0
- package/src/SceneManager.js +162 -0
- package/src/components/hotspot-listener.js +114 -0
- package/src/editor/css/main.css +1002 -0
- package/src/editor/editor-entry.css +4 -0
- package/src/editor/editor-entry.js +30 -0
- package/src/editor/js/editor.js +629 -0
- package/src/editor/js/export-manager.js +286 -0
- package/src/editor/js/hotspot-editor.js +237 -0
- package/src/editor/js/preview-controller.js +556 -0
- package/src/editor/js/scene-manager.js +202 -0
- package/src/editor/js/storage-manager.js +193 -0
- package/src/editor/js/ui-controller.js +387 -0
- package/src/editor/js/ui-init.js +164 -0
- package/src/editor/js/utils.js +217 -0
- package/src/index.js +245 -0
|
@@ -0,0 +1,2853 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Utility Functions
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate a unique ID
|
|
8
|
+
*/
|
|
9
|
+
function generateId(prefix = 'id') {
|
|
10
|
+
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sanitize string for use as ID
|
|
15
|
+
*/
|
|
16
|
+
function sanitizeId$1(str) {
|
|
17
|
+
return str.toLowerCase()
|
|
18
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
19
|
+
.replace(/^_+|_+$/g, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generate thumbnail from image file
|
|
24
|
+
*/
|
|
25
|
+
async function generateThumbnail(file, maxWidth = 200, maxHeight = 150) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const reader = new FileReader();
|
|
28
|
+
|
|
29
|
+
reader.onload = (e) => {
|
|
30
|
+
const img = new Image();
|
|
31
|
+
|
|
32
|
+
img.onload = () => {
|
|
33
|
+
const canvas = document.createElement('canvas');
|
|
34
|
+
const ctx = canvas.getContext('2d');
|
|
35
|
+
|
|
36
|
+
let width = img.width;
|
|
37
|
+
let height = img.height;
|
|
38
|
+
|
|
39
|
+
if (width > height) {
|
|
40
|
+
if (width > maxWidth) {
|
|
41
|
+
height *= maxWidth / width;
|
|
42
|
+
width = maxWidth;
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
if (height > maxHeight) {
|
|
46
|
+
width *= maxHeight / height;
|
|
47
|
+
height = maxHeight;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
canvas.width = width;
|
|
52
|
+
canvas.height = height;
|
|
53
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
54
|
+
|
|
55
|
+
resolve(canvas.toDataURL('image/jpeg', 0.7));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
img.onerror = reject;
|
|
59
|
+
img.src = e.target.result;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
reader.onerror = reject;
|
|
63
|
+
reader.readAsDataURL(file);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Load image file as data URL
|
|
69
|
+
*/
|
|
70
|
+
async function loadImageAsDataUrl(file) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const reader = new FileReader();
|
|
73
|
+
reader.onload = (e) => resolve(e.target.result);
|
|
74
|
+
reader.onerror = reject;
|
|
75
|
+
reader.readAsDataURL(file);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Show toast notification
|
|
81
|
+
*/
|
|
82
|
+
function showToast$1(message, type = 'info', duration = 3000) {
|
|
83
|
+
const toast = document.getElementById('toast');
|
|
84
|
+
toast.textContent = message;
|
|
85
|
+
toast.className = `toast ${type}`;
|
|
86
|
+
toast.classList.add('show');
|
|
87
|
+
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
toast.classList.remove('show');
|
|
90
|
+
}, duration);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Show modal
|
|
95
|
+
*/
|
|
96
|
+
function showModal(modalId) {
|
|
97
|
+
const modal = document.getElementById(modalId);
|
|
98
|
+
if (modal) {
|
|
99
|
+
modal.classList.add('show');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Hide modal
|
|
105
|
+
*/
|
|
106
|
+
function hideModal$1(modalId) {
|
|
107
|
+
const modal = document.getElementById(modalId);
|
|
108
|
+
if (modal) {
|
|
109
|
+
modal.classList.remove('show');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Format file size
|
|
115
|
+
*/
|
|
116
|
+
function formatFileSize(bytes) {
|
|
117
|
+
if (bytes === 0) return '0 Bytes';
|
|
118
|
+
const k = 1024;
|
|
119
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
120
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
121
|
+
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Download text as file
|
|
126
|
+
*/
|
|
127
|
+
function downloadTextAsFile(text, filename) {
|
|
128
|
+
const blob = new Blob([text], { type: 'text/plain' });
|
|
129
|
+
const url = URL.createObjectURL(blob);
|
|
130
|
+
const a = document.createElement('a');
|
|
131
|
+
a.href = url;
|
|
132
|
+
a.download = filename;
|
|
133
|
+
document.body.appendChild(a);
|
|
134
|
+
a.click();
|
|
135
|
+
document.body.removeChild(a);
|
|
136
|
+
URL.revokeObjectURL(url);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Debounce function
|
|
141
|
+
*/
|
|
142
|
+
function debounce(func, wait) {
|
|
143
|
+
let timeout;
|
|
144
|
+
return function executedFunction(...args) {
|
|
145
|
+
const later = () => {
|
|
146
|
+
clearTimeout(timeout);
|
|
147
|
+
func(...args);
|
|
148
|
+
};
|
|
149
|
+
clearTimeout(timeout);
|
|
150
|
+
timeout = setTimeout(later, wait);
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Convert position object to string
|
|
156
|
+
*/
|
|
157
|
+
function positionToString(pos) {
|
|
158
|
+
return `${pos.x.toFixed(1)} ${pos.y.toFixed(1)} ${pos.z.toFixed(1)}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Parse position string to object
|
|
163
|
+
*/
|
|
164
|
+
function parsePosition(str) {
|
|
165
|
+
const parts = str.split(' ').map(Number);
|
|
166
|
+
return { x: parts[0] || 0, y: parts[1] || 0, z: parts[2] || 0 };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Validate email
|
|
171
|
+
*/
|
|
172
|
+
function isValidEmail(email) {
|
|
173
|
+
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
174
|
+
return re.test(email);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Deep clone object
|
|
179
|
+
*/
|
|
180
|
+
function deepClone$1(obj) {
|
|
181
|
+
return JSON.parse(JSON.stringify(obj));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
var utils = /*#__PURE__*/Object.freeze({
|
|
185
|
+
__proto__: null,
|
|
186
|
+
debounce: debounce,
|
|
187
|
+
deepClone: deepClone$1,
|
|
188
|
+
downloadTextAsFile: downloadTextAsFile,
|
|
189
|
+
formatFileSize: formatFileSize,
|
|
190
|
+
generateId: generateId,
|
|
191
|
+
generateThumbnail: generateThumbnail,
|
|
192
|
+
hideModal: hideModal$1,
|
|
193
|
+
isValidEmail: isValidEmail,
|
|
194
|
+
loadImageAsDataUrl: loadImageAsDataUrl,
|
|
195
|
+
parsePosition: parsePosition,
|
|
196
|
+
positionToString: positionToString,
|
|
197
|
+
sanitizeId: sanitizeId$1,
|
|
198
|
+
showModal: showModal,
|
|
199
|
+
showToast: showToast$1
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Storage Manager - Handles LocalStorage operations
|
|
203
|
+
|
|
204
|
+
let ProjectStorageManager$1 = class ProjectStorageManager {
|
|
205
|
+
constructor() {
|
|
206
|
+
this.storageKey = "swt_project";
|
|
207
|
+
this.autoSaveInterval = null;
|
|
208
|
+
this.autoSaveDelay = 30000; // 30 seconds
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Save project to localStorage
|
|
213
|
+
*/
|
|
214
|
+
saveProject(projectData) {
|
|
215
|
+
try {
|
|
216
|
+
const json = JSON.stringify(projectData);
|
|
217
|
+
localStorage.setItem(this.storageKey, json);
|
|
218
|
+
localStorage.setItem(
|
|
219
|
+
this.storageKey + "_lastSaved",
|
|
220
|
+
new Date().toISOString()
|
|
221
|
+
);
|
|
222
|
+
return true;
|
|
223
|
+
} catch (error) {
|
|
224
|
+
console.error("Failed to save project:", error);
|
|
225
|
+
if (error.name === "QuotaExceededError") {
|
|
226
|
+
showToast$1("Storage quota exceeded. Project too large!", "error");
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Load project from localStorage
|
|
234
|
+
*/
|
|
235
|
+
loadProject() {
|
|
236
|
+
try {
|
|
237
|
+
const json = localStorage.getItem(this.storageKey);
|
|
238
|
+
if (json) {
|
|
239
|
+
const projectData = JSON.parse(json);
|
|
240
|
+
|
|
241
|
+
// Validate and migrate data if needed
|
|
242
|
+
if (!this.validateProjectData(projectData)) {
|
|
243
|
+
console.error("Invalid project data structure");
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return projectData;
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
console.error("Failed to load project:", error);
|
|
252
|
+
showToast$1("Failed to load project", "error");
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Validate project data structure
|
|
259
|
+
*/
|
|
260
|
+
validateProjectData(projectData) {
|
|
261
|
+
if (!projectData || typeof projectData !== "object") {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Check if scenes array exists and is valid
|
|
266
|
+
if (!projectData.scenes || !Array.isArray(projectData.scenes)) {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Validate each scene has required properties
|
|
271
|
+
for (const scene of projectData.scenes) {
|
|
272
|
+
if (!scene || typeof scene !== "object") {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Required scene properties
|
|
277
|
+
if (!scene.id || typeof scene.id !== "string") {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// imageUrl is required for scenes to be valid
|
|
282
|
+
if (!scene.imageUrl || typeof scene.imageUrl !== "string") {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Hotspots array should exist (can be empty)
|
|
287
|
+
if (!Array.isArray(scene.hotspots)) {
|
|
288
|
+
scene.hotspots = []; // Auto-fix missing hotspots array
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Ensure config exists
|
|
293
|
+
if (!projectData.config || typeof projectData.config !== "object") {
|
|
294
|
+
projectData.config = { title: "My Virtual Tour" };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Clear project from localStorage
|
|
302
|
+
*/
|
|
303
|
+
clearProject() {
|
|
304
|
+
try {
|
|
305
|
+
localStorage.removeItem(this.storageKey);
|
|
306
|
+
localStorage.removeItem(this.storageKey + "_lastSaved");
|
|
307
|
+
return true;
|
|
308
|
+
} catch (error) {
|
|
309
|
+
console.error("Failed to clear project:", error);
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Check if project exists in localStorage
|
|
316
|
+
*/
|
|
317
|
+
hasProject() {
|
|
318
|
+
return localStorage.getItem(this.storageKey) !== null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Get last saved date
|
|
323
|
+
*/
|
|
324
|
+
getLastSavedDate() {
|
|
325
|
+
const dateStr = localStorage.getItem(this.storageKey + "_lastSaved");
|
|
326
|
+
return dateStr ? new Date(dateStr) : null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Start auto-save
|
|
331
|
+
*/
|
|
332
|
+
startAutoSave(callback) {
|
|
333
|
+
this.stopAutoSave();
|
|
334
|
+
this.autoSaveInterval = setInterval(() => {
|
|
335
|
+
callback();
|
|
336
|
+
}, this.autoSaveDelay);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Stop auto-save
|
|
341
|
+
*/
|
|
342
|
+
stopAutoSave() {
|
|
343
|
+
if (this.autoSaveInterval) {
|
|
344
|
+
clearInterval(this.autoSaveInterval);
|
|
345
|
+
this.autoSaveInterval = null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Export project to file
|
|
351
|
+
*/
|
|
352
|
+
exportToFile(projectData, filename = "tour.json") {
|
|
353
|
+
try {
|
|
354
|
+
const json = JSON.stringify(projectData, null, 2);
|
|
355
|
+
downloadTextAsFile(json, filename);
|
|
356
|
+
return true;
|
|
357
|
+
} catch (error) {
|
|
358
|
+
console.error("Failed to export project:", error);
|
|
359
|
+
showToast$1("Failed to export project", "error");
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Import project from file
|
|
366
|
+
*/
|
|
367
|
+
async importFromFile(file) {
|
|
368
|
+
return new Promise((resolve, reject) => {
|
|
369
|
+
const reader = new FileReader();
|
|
370
|
+
|
|
371
|
+
reader.onload = (e) => {
|
|
372
|
+
try {
|
|
373
|
+
const projectData = JSON.parse(e.target.result);
|
|
374
|
+
resolve(projectData);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
console.error("Failed to parse project file:", error);
|
|
377
|
+
showToast$1("Invalid project file", "error");
|
|
378
|
+
reject(error);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
reader.onerror = () => {
|
|
383
|
+
console.error("Failed to read file:", reader.error);
|
|
384
|
+
showToast$1("Failed to read file", "error");
|
|
385
|
+
reject(reader.error);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
reader.readAsText(file);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// Scene Manager - Handles scene operations
|
|
394
|
+
|
|
395
|
+
let SceneManagerEditor$1 = class SceneManagerEditor {
|
|
396
|
+
constructor(editor) {
|
|
397
|
+
this.editor = editor;
|
|
398
|
+
this.scenes = [];
|
|
399
|
+
this.currentSceneIndex = -1;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Add new scene
|
|
404
|
+
*/
|
|
405
|
+
async addScene(file) {
|
|
406
|
+
try {
|
|
407
|
+
// Generate thumbnail
|
|
408
|
+
const thumbnail = await generateThumbnail(file);
|
|
409
|
+
|
|
410
|
+
// Load full image
|
|
411
|
+
const imageDataUrl = await loadImageAsDataUrl(file);
|
|
412
|
+
|
|
413
|
+
const scene = {
|
|
414
|
+
id: sanitizeId$1(file.name.replace(/\.[^/.]+$/, "")),
|
|
415
|
+
name: file.name.replace(/\.[^/.]+$/, ""),
|
|
416
|
+
imageUrl: imageDataUrl,
|
|
417
|
+
thumbnail: thumbnail,
|
|
418
|
+
hotspots: [],
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
this.scenes.push(scene);
|
|
422
|
+
this.currentSceneIndex = this.scenes.length - 1;
|
|
423
|
+
showToast$1(`Scene "${scene.name}" added successfully`, "success");
|
|
424
|
+
return scene;
|
|
425
|
+
} catch (error) {
|
|
426
|
+
console.error("Failed to add scene:", error);
|
|
427
|
+
showToast$1("Failed to add scene", "error");
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Remove scene by index
|
|
434
|
+
*/
|
|
435
|
+
removeScene(index) {
|
|
436
|
+
if (index >= 0 && index < this.scenes.length) {
|
|
437
|
+
const scene = this.scenes[index];
|
|
438
|
+
|
|
439
|
+
// Confirm deletion
|
|
440
|
+
if (!confirm(`Are you sure you want to delete scene "${scene.name}"?`)) {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
this.scenes.splice(index, 1);
|
|
445
|
+
|
|
446
|
+
// Update current scene index
|
|
447
|
+
if (this.currentSceneIndex === index) {
|
|
448
|
+
this.currentSceneIndex = Math.min(
|
|
449
|
+
this.currentSceneIndex,
|
|
450
|
+
this.scenes.length - 1
|
|
451
|
+
);
|
|
452
|
+
} else if (this.currentSceneIndex > index) {
|
|
453
|
+
this.currentSceneIndex--;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
showToast$1(`Scene "${scene.name}" removed`, "success");
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Get scene by index
|
|
464
|
+
*/
|
|
465
|
+
getScene(index) {
|
|
466
|
+
return this.scenes[index] || null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Get scene by ID
|
|
471
|
+
*/
|
|
472
|
+
getSceneById(id) {
|
|
473
|
+
return this.scenes.find((s) => s.id === id) || null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Update scene property
|
|
478
|
+
*/
|
|
479
|
+
updateScene(index, property, value) {
|
|
480
|
+
if (index >= 0 && index < this.scenes.length) {
|
|
481
|
+
this.scenes[index][property] = value;
|
|
482
|
+
|
|
483
|
+
// If updating ID, update all hotspot target references
|
|
484
|
+
if (property === "id") {
|
|
485
|
+
this.scenes.forEach((scene) => {
|
|
486
|
+
scene.hotspots.forEach((hotspot) => {
|
|
487
|
+
if (hotspot.targetSceneId === this.scenes[index].id) {
|
|
488
|
+
hotspot.targetSceneId = value;
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Reorder scenes
|
|
501
|
+
*/
|
|
502
|
+
reorderScenes(fromIndex, toIndex) {
|
|
503
|
+
if (
|
|
504
|
+
fromIndex >= 0 &&
|
|
505
|
+
fromIndex < this.scenes.length &&
|
|
506
|
+
toIndex >= 0 &&
|
|
507
|
+
toIndex < this.scenes.length
|
|
508
|
+
) {
|
|
509
|
+
const scene = this.scenes.splice(fromIndex, 1)[0];
|
|
510
|
+
this.scenes.splice(toIndex, 0, scene);
|
|
511
|
+
|
|
512
|
+
// Update current scene index
|
|
513
|
+
if (this.currentSceneIndex === fromIndex) {
|
|
514
|
+
this.currentSceneIndex = toIndex;
|
|
515
|
+
} else if (
|
|
516
|
+
fromIndex < this.currentSceneIndex &&
|
|
517
|
+
toIndex >= this.currentSceneIndex
|
|
518
|
+
) {
|
|
519
|
+
this.currentSceneIndex--;
|
|
520
|
+
} else if (
|
|
521
|
+
fromIndex > this.currentSceneIndex &&
|
|
522
|
+
toIndex <= this.currentSceneIndex
|
|
523
|
+
) {
|
|
524
|
+
this.currentSceneIndex++;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Get current scene
|
|
534
|
+
*/
|
|
535
|
+
getCurrentScene() {
|
|
536
|
+
return this.getScene(this.currentSceneIndex);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Set current scene by index
|
|
541
|
+
*/
|
|
542
|
+
setCurrentScene(index) {
|
|
543
|
+
if (index >= 0 && index < this.scenes.length) {
|
|
544
|
+
this.currentSceneIndex = index;
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Get all scenes
|
|
552
|
+
*/
|
|
553
|
+
getAllScenes() {
|
|
554
|
+
return this.scenes;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Get all scenes (alias for getAllScenes)
|
|
559
|
+
*/
|
|
560
|
+
getScenes() {
|
|
561
|
+
return this.scenes;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Clear all scenes
|
|
566
|
+
*/
|
|
567
|
+
clearScenes() {
|
|
568
|
+
if (this.scenes.length > 0) {
|
|
569
|
+
if (!confirm("Are you sure you want to clear all scenes?")) {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
this.scenes = [];
|
|
575
|
+
this.currentSceneIndex = -1;
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Load scenes from data
|
|
581
|
+
*/
|
|
582
|
+
loadScenes(scenesData) {
|
|
583
|
+
this.scenes = scenesData || [];
|
|
584
|
+
this.currentSceneIndex = this.scenes.length > 0 ? 0 : -1;
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
// Hotspot Editor - Handles hotspot placement and editing
|
|
589
|
+
|
|
590
|
+
let HotspotEditor$1 = class HotspotEditor {
|
|
591
|
+
constructor(editor) {
|
|
592
|
+
this.editor = editor;
|
|
593
|
+
this.currentHotspotIndex = -1;
|
|
594
|
+
this.placementMode = false;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Enable hotspot placement mode
|
|
599
|
+
*/
|
|
600
|
+
enablePlacementMode() {
|
|
601
|
+
const scene = this.editor.sceneManager.getCurrentScene();
|
|
602
|
+
if (!scene) {
|
|
603
|
+
showToast$1('Please select a scene first', 'error');
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
this.placementMode = true;
|
|
608
|
+
|
|
609
|
+
// Visual feedback
|
|
610
|
+
document.body.style.cursor = 'crosshair';
|
|
611
|
+
const preview = document.getElementById('preview');
|
|
612
|
+
if (preview) {
|
|
613
|
+
preview.style.border = '3px solid #4CC3D9';
|
|
614
|
+
preview.style.boxShadow = '0 0 20px rgba(76, 195, 217, 0.5)';
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Update button state
|
|
618
|
+
const btn = document.getElementById('addHotspotBtn');
|
|
619
|
+
if (btn) {
|
|
620
|
+
btn.textContent = 'Click on Preview...';
|
|
621
|
+
btn.classList.add('btn-active');
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
showToast$1('Click on the 360° preview to place hotspot', 'info', 5000);
|
|
625
|
+
return true;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Disable hotspot placement mode
|
|
630
|
+
*/
|
|
631
|
+
disablePlacementMode() {
|
|
632
|
+
this.placementMode = false;
|
|
633
|
+
document.body.style.cursor = 'default';
|
|
634
|
+
|
|
635
|
+
// Remove visual feedback
|
|
636
|
+
const preview = document.getElementById('preview');
|
|
637
|
+
if (preview) {
|
|
638
|
+
preview.style.border = '';
|
|
639
|
+
preview.style.boxShadow = '';
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Reset button state
|
|
643
|
+
const btn = document.getElementById('addHotspotBtn');
|
|
644
|
+
if (btn) {
|
|
645
|
+
btn.textContent = '+ Add Hotspot';
|
|
646
|
+
btn.classList.remove('btn-active');
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Add hotspot at position
|
|
652
|
+
*/
|
|
653
|
+
addHotspot(position, targetSceneId = '') {
|
|
654
|
+
const scene = this.editor.sceneManager.getCurrentScene();
|
|
655
|
+
if (!scene) {
|
|
656
|
+
showToast$1('No scene selected', 'error');
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const hotspot = {
|
|
661
|
+
id: generateId('hotspot'),
|
|
662
|
+
type: 'navigation',
|
|
663
|
+
position: position,
|
|
664
|
+
targetSceneId: targetSceneId,
|
|
665
|
+
title: 'New Hotspot',
|
|
666
|
+
description: '',
|
|
667
|
+
color: '#00ff00',
|
|
668
|
+
icon: ''
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
scene.hotspots.push(hotspot);
|
|
672
|
+
this.currentHotspotIndex = scene.hotspots.length - 1;
|
|
673
|
+
|
|
674
|
+
this.disablePlacementMode();
|
|
675
|
+
showToast$1('Hotspot added', 'success');
|
|
676
|
+
|
|
677
|
+
return hotspot;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Remove hotspot
|
|
682
|
+
*/
|
|
683
|
+
removeHotspot(index) {
|
|
684
|
+
const scene = this.editor.sceneManager.getCurrentScene();
|
|
685
|
+
if (!scene || index < 0 || index >= scene.hotspots.length) {
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (!confirm('Are you sure you want to delete this hotspot?')) {
|
|
690
|
+
return false;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
scene.hotspots.splice(index, 1);
|
|
694
|
+
|
|
695
|
+
// Update current index
|
|
696
|
+
if (this.currentHotspotIndex === index) {
|
|
697
|
+
this.currentHotspotIndex = -1;
|
|
698
|
+
} else if (this.currentHotspotIndex > index) {
|
|
699
|
+
this.currentHotspotIndex--;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
showToast$1('Hotspot removed', 'success');
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Update hotspot property
|
|
708
|
+
*/
|
|
709
|
+
updateHotspot(index, property, value) {
|
|
710
|
+
const scene = this.editor.sceneManager.getCurrentScene();
|
|
711
|
+
if (!scene || index < 0 || index >= scene.hotspots.length) {
|
|
712
|
+
return false;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
scene.hotspots[index][property] = value;
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Get hotspot by index
|
|
721
|
+
*/
|
|
722
|
+
getHotspot(index) {
|
|
723
|
+
const scene = this.editor.sceneManager.getCurrentScene();
|
|
724
|
+
if (!scene || index < 0 || index >= scene.hotspots.length) {
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
return scene.hotspots[index];
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Update hotspot position
|
|
732
|
+
*/
|
|
733
|
+
updateHotspotPosition(index, position) {
|
|
734
|
+
return this.updateHotspot(index, 'position', position);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Get current hotspot
|
|
739
|
+
*/
|
|
740
|
+
getCurrentHotspot() {
|
|
741
|
+
const scene = this.editor.sceneManager.getCurrentScene();
|
|
742
|
+
if (!scene || this.currentHotspotIndex < 0) {
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
return scene.hotspots[this.currentHotspotIndex] || null;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Set current hotspot
|
|
750
|
+
*/
|
|
751
|
+
setCurrentHotspot(index) {
|
|
752
|
+
const scene = this.editor.sceneManager.getCurrentScene();
|
|
753
|
+
if (!scene || index < 0 || index >= scene.hotspots.length) {
|
|
754
|
+
this.currentHotspotIndex = -1;
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
this.currentHotspotIndex = index;
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Get all hotspots for current scene
|
|
764
|
+
*/
|
|
765
|
+
getAllHotspots() {
|
|
766
|
+
const scene = this.editor.sceneManager.getCurrentScene();
|
|
767
|
+
return scene ? scene.hotspots : [];
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Duplicate hotspot
|
|
772
|
+
*/
|
|
773
|
+
duplicateHotspot(index) {
|
|
774
|
+
const scene = this.editor.sceneManager.getCurrentScene();
|
|
775
|
+
if (!scene || index < 0 || index >= scene.hotspots.length) {
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const original = scene.hotspots[index];
|
|
780
|
+
const duplicate = deepClone(original);
|
|
781
|
+
duplicate.id = generateId('hotspot');
|
|
782
|
+
duplicate.title = original.title + ' (Copy)';
|
|
783
|
+
|
|
784
|
+
// Offset position slightly
|
|
785
|
+
duplicate.position = {
|
|
786
|
+
x: original.position.x + 0.5,
|
|
787
|
+
y: original.position.y,
|
|
788
|
+
z: original.position.z
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
scene.hotspots.push(duplicate);
|
|
792
|
+
this.currentHotspotIndex = scene.hotspots.length - 1;
|
|
793
|
+
|
|
794
|
+
showToast$1('Hotspot duplicated', 'success');
|
|
795
|
+
return duplicate;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Clear all hotspots
|
|
800
|
+
*/
|
|
801
|
+
clearAllHotspots() {
|
|
802
|
+
const scene = this.editor.sceneManager.getCurrentScene();
|
|
803
|
+
if (!scene) {
|
|
804
|
+
return false;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (scene.hotspots.length === 0) {
|
|
808
|
+
return true;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (!confirm('Are you sure you want to remove all hotspots from this scene?')) {
|
|
812
|
+
return false;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
scene.hotspots = [];
|
|
816
|
+
this.currentHotspotIndex = -1;
|
|
817
|
+
|
|
818
|
+
showToast$1('All hotspots removed', 'success');
|
|
819
|
+
return true;
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
// Preview Controller - Manages A-Frame preview integration using SWT library
|
|
824
|
+
|
|
825
|
+
let PreviewController$1 = class PreviewController {
|
|
826
|
+
constructor(editor) {
|
|
827
|
+
this.editor = editor;
|
|
828
|
+
this.tour = null;
|
|
829
|
+
this.isInitialized = false;
|
|
830
|
+
this.previewContainer = null;
|
|
831
|
+
this.hasLoadedScene = false; // Track if we've ever loaded a scene
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Initialize A-Frame preview
|
|
836
|
+
*/
|
|
837
|
+
async init() {
|
|
838
|
+
this.previewContainer = document.getElementById("preview");
|
|
839
|
+
if (!this.previewContainer) {
|
|
840
|
+
console.error("Preview element not found");
|
|
841
|
+
return false;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Wait for A-Frame to be loaded
|
|
845
|
+
if (typeof AFRAME === "undefined") {
|
|
846
|
+
await this.waitForLibrary("AFRAME", 5000);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Wait for SWT library to be loaded
|
|
850
|
+
if (typeof SWT === "undefined") {
|
|
851
|
+
await this.waitForLibrary("SWT", 5000);
|
|
852
|
+
}
|
|
853
|
+
this.isInitialized = true;
|
|
854
|
+
return true;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Wait for a global library to be available
|
|
859
|
+
*/
|
|
860
|
+
async waitForLibrary(libraryName, timeout = 5000) {
|
|
861
|
+
const startTime = Date.now();
|
|
862
|
+
|
|
863
|
+
while (typeof window[libraryName] === "undefined") {
|
|
864
|
+
if (Date.now() - startTime > timeout) {
|
|
865
|
+
throw new Error(`Timeout waiting for ${libraryName} to load`);
|
|
866
|
+
}
|
|
867
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Load scene into preview using SWT library
|
|
873
|
+
*/
|
|
874
|
+
async loadScene(scene, preserveCameraRotation = true) {
|
|
875
|
+
if (!this.isInitialized || !scene) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Validate scene has required data
|
|
880
|
+
if (!scene.imageUrl || !scene.id) {
|
|
881
|
+
console.error("Invalid scene data:", scene);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Show loading animation
|
|
886
|
+
this.showLoading();
|
|
887
|
+
|
|
888
|
+
// Save camera rotation before destroying scene
|
|
889
|
+
let savedRotation = null;
|
|
890
|
+
if (preserveCameraRotation && this.tour) {
|
|
891
|
+
savedRotation = this.getCameraRotation();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Destroy existing tour if any
|
|
895
|
+
if (this.tour) {
|
|
896
|
+
try {
|
|
897
|
+
this.tour.destroy();
|
|
898
|
+
} catch (error) {
|
|
899
|
+
console.error("Error destroying tour:", error);
|
|
900
|
+
}
|
|
901
|
+
this.tour = null;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Clear preview container carefully
|
|
905
|
+
// Only do complex cleanup if we've actually loaded a scene before
|
|
906
|
+
if (this.hasLoadedScene) {
|
|
907
|
+
const existingScene = this.previewContainer.querySelector("a-scene");
|
|
908
|
+
if (existingScene) {
|
|
909
|
+
try {
|
|
910
|
+
// Remove the scene element - A-Frame will handle cleanup if it's ready
|
|
911
|
+
this.previewContainer.removeChild(existingScene);
|
|
912
|
+
} catch (error) {
|
|
913
|
+
console.error("Error removing scene:", error);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Clear any remaining children (loading overlays, empty state, etc)
|
|
918
|
+
while (this.previewContainer.firstChild) {
|
|
919
|
+
this.previewContainer.removeChild(this.previewContainer.firstChild);
|
|
920
|
+
}
|
|
921
|
+
} else {
|
|
922
|
+
// First load - only remove non-A-Frame elements (like empty state divs)
|
|
923
|
+
const children = Array.from(this.previewContainer.children);
|
|
924
|
+
children.forEach(child => {
|
|
925
|
+
// Only remove if it's NOT an a-scene (shouldn't be any, but be safe)
|
|
926
|
+
if (child.tagName.toLowerCase() !== 'a-scene') {
|
|
927
|
+
this.previewContainer.removeChild(child);
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Create loading overlay (will be removed after scene loads)
|
|
933
|
+
const loadingOverlay = this.createLoadingOverlay();
|
|
934
|
+
this.previewContainer.appendChild(loadingOverlay);
|
|
935
|
+
|
|
936
|
+
// Create A-Frame scene
|
|
937
|
+
const aframeScene = document.createElement("a-scene");
|
|
938
|
+
aframeScene.id = "preview-scene";
|
|
939
|
+
aframeScene.setAttribute("embedded", "");
|
|
940
|
+
aframeScene.setAttribute("vr-mode-ui", "enabled: false;");
|
|
941
|
+
this.previewContainer.appendChild(aframeScene);
|
|
942
|
+
|
|
943
|
+
// Give A-Frame a moment to start initializing before we proceed
|
|
944
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
945
|
+
|
|
946
|
+
// Build tour config for this single scene
|
|
947
|
+
// Transform editor scene format to library format
|
|
948
|
+
(scene.hotspots || []).map((h) => ({
|
|
949
|
+
id: h.id,
|
|
950
|
+
position: h.position,
|
|
951
|
+
action: {
|
|
952
|
+
type: h.type === "navigation" ? "navigateTo" : h.type,
|
|
953
|
+
target: h.targetSceneId,
|
|
954
|
+
},
|
|
955
|
+
appearance: {
|
|
956
|
+
color: h.color || "#00ff00",
|
|
957
|
+
icon: h.icon || null,
|
|
958
|
+
scale: h.scale || "1 1 1",
|
|
959
|
+
},
|
|
960
|
+
tooltip: {
|
|
961
|
+
text: h.title || "Hotspot",
|
|
962
|
+
},
|
|
963
|
+
}));
|
|
964
|
+
|
|
965
|
+
({
|
|
966
|
+
id: scene.id,
|
|
967
|
+
name: scene.name,
|
|
968
|
+
panorama: scene.imageUrl});
|
|
969
|
+
|
|
970
|
+
// Build scenes object with ALL scenes (for navigation to work)
|
|
971
|
+
const allScenes = {};
|
|
972
|
+
const editorScenes = this.editor.sceneManager.scenes || [];
|
|
973
|
+
editorScenes.forEach((s) => {
|
|
974
|
+
const sceneHotspots = (s.hotspots || []).map((h) => ({
|
|
975
|
+
id: h.id,
|
|
976
|
+
position: h.position,
|
|
977
|
+
action: {
|
|
978
|
+
type: h.type === "navigation" ? "navigateTo" : h.type,
|
|
979
|
+
target: h.targetSceneId,
|
|
980
|
+
},
|
|
981
|
+
appearance: {
|
|
982
|
+
color: h.color || "#00ff00",
|
|
983
|
+
icon: h.icon || null,
|
|
984
|
+
scale: h.scale || "1 1 1",
|
|
985
|
+
},
|
|
986
|
+
tooltip: {
|
|
987
|
+
text: h.title || "Hotspot",
|
|
988
|
+
},
|
|
989
|
+
}));
|
|
990
|
+
|
|
991
|
+
allScenes[s.id] = {
|
|
992
|
+
id: s.id,
|
|
993
|
+
name: s.name,
|
|
994
|
+
panorama: s.imageUrl,
|
|
995
|
+
hotspots: sceneHotspots,
|
|
996
|
+
};
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
const tourConfig = {
|
|
1000
|
+
title: scene.name,
|
|
1001
|
+
initialScene: scene.id,
|
|
1002
|
+
scenes: allScenes,
|
|
1003
|
+
settings: {
|
|
1004
|
+
autoRotate: false,
|
|
1005
|
+
showCompass: false,
|
|
1006
|
+
},
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
try {
|
|
1010
|
+
// Create new tour instance
|
|
1011
|
+
this.tour = new SWT.Tour(aframeScene, tourConfig);
|
|
1012
|
+
|
|
1013
|
+
// Set up event listeners
|
|
1014
|
+
this.tour.addEventListener("tour-started", (e) => {});
|
|
1015
|
+
|
|
1016
|
+
this.tour.addEventListener("scene-loaded", (e) => {});
|
|
1017
|
+
|
|
1018
|
+
this.tour.addEventListener("hotspot-activated", (e) => {
|
|
1019
|
+
// Find the hotspot index by ID and select it
|
|
1020
|
+
const hotspotId = e.detail?.hotspotId;
|
|
1021
|
+
if (hotspotId) {
|
|
1022
|
+
const scene = this.editor.sceneManager.getCurrentScene();
|
|
1023
|
+
if (scene) {
|
|
1024
|
+
const hotspotIndex = scene.hotspots.findIndex(
|
|
1025
|
+
(h) => h.id === hotspotId
|
|
1026
|
+
);
|
|
1027
|
+
if (hotspotIndex >= 0) {
|
|
1028
|
+
this.editor.selectHotspot(hotspotIndex);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
// Start the tour
|
|
1035
|
+
await this.tour.start();
|
|
1036
|
+
|
|
1037
|
+
// Mark that we've successfully loaded a scene
|
|
1038
|
+
this.hasLoadedScene = true;
|
|
1039
|
+
|
|
1040
|
+
// Hide loading animation after scene loads
|
|
1041
|
+
this.hideLoading();
|
|
1042
|
+
|
|
1043
|
+
// Restore camera rotation if preserved
|
|
1044
|
+
if (savedRotation && preserveCameraRotation) {
|
|
1045
|
+
this.setCameraRotation(savedRotation);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// Setup click handler after a short delay to ensure A-Frame is ready
|
|
1049
|
+
setTimeout(() => {
|
|
1050
|
+
this.setupClickHandler();
|
|
1051
|
+
}, 500);
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
console.error("Failed to load preview:", error);
|
|
1054
|
+
showToast$1("Failed to load preview: " + error.message, "error");
|
|
1055
|
+
// Hide loading on error
|
|
1056
|
+
this.hideLoading();
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Setup click handler for hotspot placement
|
|
1062
|
+
*/
|
|
1063
|
+
setupClickHandler() {
|
|
1064
|
+
if (!this.tour) {
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const aframeScene = this.previewContainer.querySelector("a-scene");
|
|
1069
|
+
if (!aframeScene) {
|
|
1070
|
+
setTimeout(() => this.setupClickHandler(), 200); // Retry
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Remove any existing click handler to avoid duplicates
|
|
1075
|
+
if (this.clickHandler) {
|
|
1076
|
+
aframeScene.removeEventListener("click", this.clickHandler);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Create and store the click handler
|
|
1080
|
+
this.clickHandler = (evt) => {
|
|
1081
|
+
if (!this.editor.hotspotEditor.placementMode) {
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Try to get intersection from event detail first
|
|
1086
|
+
let intersection = evt.detail?.intersection;
|
|
1087
|
+
|
|
1088
|
+
// If no intersection, perform manual raycasting
|
|
1089
|
+
if (!intersection) {
|
|
1090
|
+
const camera = aframeScene.querySelector("[camera]");
|
|
1091
|
+
const sky = aframeScene.querySelector("a-sky");
|
|
1092
|
+
|
|
1093
|
+
if (!camera || !sky) {
|
|
1094
|
+
showToast$1("Scene not ready, please try again", "warning");
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Get mouse position relative to canvas
|
|
1099
|
+
const canvas = aframeScene.canvas;
|
|
1100
|
+
const rect = canvas.getBoundingClientRect();
|
|
1101
|
+
const mouse = {
|
|
1102
|
+
x: ((evt.clientX - rect.left) / rect.width) * 2 - 1,
|
|
1103
|
+
y: -((evt.clientY - rect.top) / rect.height) * 2 + 1,
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
// Perform raycasting
|
|
1107
|
+
const raycaster = new THREE.Raycaster();
|
|
1108
|
+
const cameraEl = camera.object3D;
|
|
1109
|
+
raycaster.setFromCamera(mouse, cameraEl.children[0]); // Get the actual camera
|
|
1110
|
+
|
|
1111
|
+
// Raycast against the sky sphere
|
|
1112
|
+
const intersects = raycaster.intersectObject(sky.object3D, true);
|
|
1113
|
+
|
|
1114
|
+
if (intersects.length > 0) {
|
|
1115
|
+
intersection = intersects[0];
|
|
1116
|
+
} else {
|
|
1117
|
+
showToast$1("Click on the panorama image", "warning");
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const point = intersection.point;
|
|
1123
|
+
const position = {
|
|
1124
|
+
x: parseFloat(point.x.toFixed(2)),
|
|
1125
|
+
y: parseFloat(point.y.toFixed(2)),
|
|
1126
|
+
z: parseFloat(point.z.toFixed(2)),
|
|
1127
|
+
};
|
|
1128
|
+
this.editor.addHotspotAtPosition(position);
|
|
1129
|
+
};
|
|
1130
|
+
aframeScene.addEventListener("click", this.clickHandler);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Get current camera rotation
|
|
1135
|
+
*/
|
|
1136
|
+
getCameraRotation() {
|
|
1137
|
+
const aframeScene = this.previewContainer?.querySelector("a-scene");
|
|
1138
|
+
if (!aframeScene) {
|
|
1139
|
+
return null;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const camera = aframeScene.querySelector("[camera]");
|
|
1143
|
+
if (!camera) {
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Get rotation from object3D which is more reliable
|
|
1148
|
+
const rotation = camera.object3D.rotation;
|
|
1149
|
+
const savedRotation = {
|
|
1150
|
+
x: rotation.x,
|
|
1151
|
+
y: rotation.y,
|
|
1152
|
+
z: rotation.z,
|
|
1153
|
+
};
|
|
1154
|
+
return savedRotation;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Set camera rotation
|
|
1159
|
+
*/
|
|
1160
|
+
setCameraRotation(rotation) {
|
|
1161
|
+
if (!rotation) {
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const aframeScene = this.previewContainer?.querySelector("a-scene");
|
|
1166
|
+
if (!aframeScene) {
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const camera = aframeScene.querySelector("[camera]");
|
|
1171
|
+
if (!camera) {
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Set rotation on object3D directly
|
|
1176
|
+
const setRotation = () => {
|
|
1177
|
+
if (camera.object3D) {
|
|
1178
|
+
camera.object3D.rotation.set(rotation.x, rotation.y, rotation.z);
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
|
|
1182
|
+
// Try immediately and also after a delay to ensure it sticks
|
|
1183
|
+
setRotation();
|
|
1184
|
+
setTimeout(setRotation, 100);
|
|
1185
|
+
setTimeout(setRotation, 300);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Refresh preview (reload current scene while preserving camera rotation)
|
|
1190
|
+
*/
|
|
1191
|
+
async refresh() {
|
|
1192
|
+
const scene = this.editor.sceneManager.getCurrentScene();
|
|
1193
|
+
if (scene) {
|
|
1194
|
+
// Save current camera rotation
|
|
1195
|
+
const savedRotation = this.getCameraRotation();
|
|
1196
|
+
// Reload scene
|
|
1197
|
+
await this.loadScene(scene);
|
|
1198
|
+
|
|
1199
|
+
// Restore camera rotation
|
|
1200
|
+
if (savedRotation) {
|
|
1201
|
+
this.setCameraRotation(savedRotation);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Reset camera
|
|
1208
|
+
*/
|
|
1209
|
+
resetCamera() {
|
|
1210
|
+
const camera = this.previewContainer?.querySelector("[camera]");
|
|
1211
|
+
if (camera) {
|
|
1212
|
+
camera.setAttribute("rotation", "0 0 0");
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Point camera to hotspot position
|
|
1218
|
+
*/
|
|
1219
|
+
pointCameraToHotspot(hotspotPosition) {
|
|
1220
|
+
if (!hotspotPosition) {
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const aframeScene = this.previewContainer?.querySelector("a-scene");
|
|
1225
|
+
if (!aframeScene) {
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const camera = aframeScene.querySelector("[camera]");
|
|
1230
|
+
if (!camera || !camera.object3D) {
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// Get camera position (usually at origin 0,0,0)
|
|
1235
|
+
const cameraPos = camera.object3D.position;
|
|
1236
|
+
|
|
1237
|
+
// Calculate direction vector from camera to hotspot
|
|
1238
|
+
const direction = new THREE.Vector3(
|
|
1239
|
+
hotspotPosition.x - cameraPos.x,
|
|
1240
|
+
hotspotPosition.y - cameraPos.y,
|
|
1241
|
+
hotspotPosition.z - cameraPos.z
|
|
1242
|
+
);
|
|
1243
|
+
|
|
1244
|
+
// Calculate spherical coordinates (yaw and pitch)
|
|
1245
|
+
const distance = direction.length();
|
|
1246
|
+
|
|
1247
|
+
// Pitch (up/down rotation around X-axis) - in degrees
|
|
1248
|
+
const pitch = Math.asin(direction.y / distance) * (180 / Math.PI);
|
|
1249
|
+
|
|
1250
|
+
// Yaw (left/right rotation around Y-axis) - in degrees
|
|
1251
|
+
// Using atan2 to get correct quadrant
|
|
1252
|
+
const yaw = Math.atan2(direction.x, direction.z) * (180 / Math.PI);
|
|
1253
|
+
|
|
1254
|
+
// Apply smooth rotation with animation
|
|
1255
|
+
this.animateCameraRotation(camera, { x: pitch, y: yaw, z: 0 });
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Animate camera rotation smoothly
|
|
1260
|
+
*/
|
|
1261
|
+
animateCameraRotation(camera, targetRotation, duration = 800) {
|
|
1262
|
+
if (!camera || !camera.object3D) return;
|
|
1263
|
+
|
|
1264
|
+
const startRotation = {
|
|
1265
|
+
x: camera.object3D.rotation.x * (180 / Math.PI),
|
|
1266
|
+
y: camera.object3D.rotation.y * (180 / Math.PI),
|
|
1267
|
+
z: camera.object3D.rotation.z * (180 / Math.PI),
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
// Handle angle wrapping for smooth rotation
|
|
1271
|
+
let deltaY = targetRotation.y - startRotation.y;
|
|
1272
|
+
|
|
1273
|
+
// Normalize to -180 to 180 range
|
|
1274
|
+
while (deltaY > 180) deltaY -= 360;
|
|
1275
|
+
while (deltaY < -180) deltaY += 360;
|
|
1276
|
+
|
|
1277
|
+
const endRotationY = startRotation.y + deltaY;
|
|
1278
|
+
|
|
1279
|
+
const startTime = Date.now();
|
|
1280
|
+
|
|
1281
|
+
const animate = () => {
|
|
1282
|
+
const elapsed = Date.now() - startTime;
|
|
1283
|
+
const progress = Math.min(elapsed / duration, 1);
|
|
1284
|
+
|
|
1285
|
+
// Ease-in-out function for smooth animation
|
|
1286
|
+
const eased =
|
|
1287
|
+
progress < 0.5
|
|
1288
|
+
? 2 * progress * progress
|
|
1289
|
+
: 1 - Math.pow(-2 * progress + 2, 2) / 2;
|
|
1290
|
+
|
|
1291
|
+
// Interpolate rotation
|
|
1292
|
+
const currentRotation = {
|
|
1293
|
+
x: startRotation.x + (targetRotation.x - startRotation.x) * eased,
|
|
1294
|
+
y: startRotation.y + (endRotationY - startRotation.y) * eased,
|
|
1295
|
+
z: startRotation.z + (targetRotation.z - startRotation.z) * eased,
|
|
1296
|
+
};
|
|
1297
|
+
|
|
1298
|
+
// Apply rotation (convert degrees to radians)
|
|
1299
|
+
camera.object3D.rotation.set(
|
|
1300
|
+
currentRotation.x * (Math.PI / 180),
|
|
1301
|
+
currentRotation.y * (Math.PI / 180),
|
|
1302
|
+
currentRotation.z * (Math.PI / 180)
|
|
1303
|
+
);
|
|
1304
|
+
|
|
1305
|
+
if (progress < 1) {
|
|
1306
|
+
requestAnimationFrame(animate);
|
|
1307
|
+
}
|
|
1308
|
+
};
|
|
1309
|
+
|
|
1310
|
+
animate();
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Highlight hotspot (not needed with library, but keep for compatibility)
|
|
1315
|
+
*/
|
|
1316
|
+
highlightHotspot(index) {
|
|
1317
|
+
// The library handles hotspot visualization
|
|
1318
|
+
// This is kept for API compatibility
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Update hotspot marker (refresh scene while preserving camera rotation)
|
|
1323
|
+
*/
|
|
1324
|
+
async updateHotspotMarker(index) {
|
|
1325
|
+
const scene = this.editor.sceneManager.getCurrentScene();
|
|
1326
|
+
if (!scene || !this.tour) return;
|
|
1327
|
+
|
|
1328
|
+
const hotspot = scene.hotspots[index];
|
|
1329
|
+
if (!hotspot) return;
|
|
1330
|
+
|
|
1331
|
+
// Refresh the preview to reflect changes, camera rotation will be preserved
|
|
1332
|
+
await this.refresh();
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* Create loading overlay element
|
|
1337
|
+
*/
|
|
1338
|
+
createLoadingOverlay() {
|
|
1339
|
+
const overlay = document.createElement("div");
|
|
1340
|
+
overlay.className = "preview-loading";
|
|
1341
|
+
overlay.innerHTML = `
|
|
1342
|
+
<div class="loading-spinner"></div>
|
|
1343
|
+
<div class="loading-text">Loading scene...</div>
|
|
1344
|
+
`;
|
|
1345
|
+
return overlay;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Show loading animation
|
|
1350
|
+
*/
|
|
1351
|
+
showLoading() {
|
|
1352
|
+
const existing = this.previewContainer?.querySelector(".preview-loading");
|
|
1353
|
+
if (existing) {
|
|
1354
|
+
existing.classList.remove("hidden");
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* Hide loading animation
|
|
1360
|
+
*/
|
|
1361
|
+
hideLoading() {
|
|
1362
|
+
const loading = this.previewContainer?.querySelector(".preview-loading");
|
|
1363
|
+
if (loading) {
|
|
1364
|
+
loading.classList.add("hidden");
|
|
1365
|
+
// Remove after transition
|
|
1366
|
+
setTimeout(() => {
|
|
1367
|
+
if (loading.parentNode) {
|
|
1368
|
+
loading.parentNode.removeChild(loading);
|
|
1369
|
+
}
|
|
1370
|
+
}, 300);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
|
|
1375
|
+
// UI Controller - Handles DOM manipulation and rendering
|
|
1376
|
+
|
|
1377
|
+
let UIController$1 = class UIController {
|
|
1378
|
+
constructor(editor) {
|
|
1379
|
+
this.editor = editor;
|
|
1380
|
+
this.sceneList = document.getElementById('sceneList');
|
|
1381
|
+
this.hotspotList = document.getElementById('hotspotList');
|
|
1382
|
+
this.draggedElement = null;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* Render scene list
|
|
1387
|
+
*/
|
|
1388
|
+
renderSceneList() {
|
|
1389
|
+
if (!this.sceneList) return;
|
|
1390
|
+
|
|
1391
|
+
this.sceneList.innerHTML = '';
|
|
1392
|
+
const scenes = this.editor.sceneManager.getAllScenes();
|
|
1393
|
+
const currentIndex = this.editor.sceneManager.currentSceneIndex;
|
|
1394
|
+
|
|
1395
|
+
if (scenes.length === 0) {
|
|
1396
|
+
const empty = document.createElement('div');
|
|
1397
|
+
empty.className = 'empty-state';
|
|
1398
|
+
empty.innerHTML = `
|
|
1399
|
+
<p>No scenes yet</p>
|
|
1400
|
+
<p class="hint">Click "Add Scene" to upload a 360° panorama</p>
|
|
1401
|
+
`;
|
|
1402
|
+
this.sceneList.appendChild(empty);
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
scenes.forEach((scene, index) => {
|
|
1407
|
+
const card = this.createSceneCard(scene, index, index === currentIndex);
|
|
1408
|
+
this.sceneList.appendChild(card);
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Create scene card element
|
|
1414
|
+
*/
|
|
1415
|
+
createSceneCard(scene, index, isActive) {
|
|
1416
|
+
const card = document.createElement('div');
|
|
1417
|
+
card.className = 'scene-card' + (isActive ? ' active' : '');
|
|
1418
|
+
card.draggable = true;
|
|
1419
|
+
card.dataset.index = index;
|
|
1420
|
+
|
|
1421
|
+
// Drag handle
|
|
1422
|
+
const dragHandle = document.createElement('div');
|
|
1423
|
+
dragHandle.className = 'drag-handle';
|
|
1424
|
+
dragHandle.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free v6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/></svg>';
|
|
1425
|
+
|
|
1426
|
+
// Thumbnail
|
|
1427
|
+
const thumbnail = document.createElement('img');
|
|
1428
|
+
thumbnail.src = scene.thumbnail || scene.imageUrl;
|
|
1429
|
+
thumbnail.alt = scene.name;
|
|
1430
|
+
|
|
1431
|
+
// Info
|
|
1432
|
+
const info = document.createElement('div');
|
|
1433
|
+
info.className = 'scene-info';
|
|
1434
|
+
|
|
1435
|
+
const name = document.createElement('div');
|
|
1436
|
+
name.className = 'scene-name';
|
|
1437
|
+
name.textContent = scene.name;
|
|
1438
|
+
|
|
1439
|
+
const meta = document.createElement('div');
|
|
1440
|
+
meta.className = 'scene-meta';
|
|
1441
|
+
meta.textContent = `${scene.hotspots.length} hotspot${scene.hotspots.length !== 1 ? 's' : ''}`;
|
|
1442
|
+
|
|
1443
|
+
info.appendChild(name);
|
|
1444
|
+
info.appendChild(meta);
|
|
1445
|
+
|
|
1446
|
+
// Actions
|
|
1447
|
+
const actions = document.createElement('div');
|
|
1448
|
+
actions.className = 'scene-actions';
|
|
1449
|
+
|
|
1450
|
+
const deleteBtn = document.createElement('button');
|
|
1451
|
+
deleteBtn.className = 'btn-icon';
|
|
1452
|
+
deleteBtn.innerHTML = '🗑️';
|
|
1453
|
+
deleteBtn.title = 'Delete scene';
|
|
1454
|
+
deleteBtn.onclick = (e) => {
|
|
1455
|
+
e.stopPropagation();
|
|
1456
|
+
this.editor.removeScene(index);
|
|
1457
|
+
};
|
|
1458
|
+
|
|
1459
|
+
actions.appendChild(deleteBtn);
|
|
1460
|
+
|
|
1461
|
+
card.appendChild(dragHandle);
|
|
1462
|
+
card.appendChild(thumbnail);
|
|
1463
|
+
card.appendChild(info);
|
|
1464
|
+
card.appendChild(actions);
|
|
1465
|
+
|
|
1466
|
+
// Click handler
|
|
1467
|
+
card.onclick = () => {
|
|
1468
|
+
this.editor.selectScene(index);
|
|
1469
|
+
};
|
|
1470
|
+
|
|
1471
|
+
// Drag and drop handlers
|
|
1472
|
+
this.setupDragAndDrop(card);
|
|
1473
|
+
|
|
1474
|
+
return card;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* Setup drag and drop for scene reordering
|
|
1479
|
+
*/
|
|
1480
|
+
setupDragAndDrop(card) {
|
|
1481
|
+
card.addEventListener('dragstart', (e) => {
|
|
1482
|
+
this.draggedElement = card;
|
|
1483
|
+
card.classList.add('dragging');
|
|
1484
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
card.addEventListener('dragend', () => {
|
|
1488
|
+
card.classList.remove('dragging');
|
|
1489
|
+
this.draggedElement = null;
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
card.addEventListener('dragover', (e) => {
|
|
1493
|
+
e.preventDefault();
|
|
1494
|
+
e.dataTransfer.dropEffect = 'move';
|
|
1495
|
+
|
|
1496
|
+
if (this.draggedElement && this.draggedElement !== card) {
|
|
1497
|
+
const bounding = card.getBoundingClientRect();
|
|
1498
|
+
const offset = bounding.y + bounding.height / 2;
|
|
1499
|
+
|
|
1500
|
+
if (e.clientY - offset > 0) {
|
|
1501
|
+
card.style.borderBottom = '2px solid var(--accent-color)';
|
|
1502
|
+
card.style.borderTop = '';
|
|
1503
|
+
} else {
|
|
1504
|
+
card.style.borderTop = '2px solid var(--accent-color)';
|
|
1505
|
+
card.style.borderBottom = '';
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
card.addEventListener('dragleave', () => {
|
|
1511
|
+
card.style.borderTop = '';
|
|
1512
|
+
card.style.borderBottom = '';
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
card.addEventListener('drop', (e) => {
|
|
1516
|
+
e.preventDefault();
|
|
1517
|
+
card.style.borderTop = '';
|
|
1518
|
+
card.style.borderBottom = '';
|
|
1519
|
+
|
|
1520
|
+
if (this.draggedElement && this.draggedElement !== card) {
|
|
1521
|
+
const fromIndex = parseInt(this.draggedElement.dataset.index);
|
|
1522
|
+
const toIndex = parseInt(card.dataset.index);
|
|
1523
|
+
this.editor.reorderScenes(fromIndex, toIndex);
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/**
|
|
1529
|
+
* Render hotspot list
|
|
1530
|
+
*/
|
|
1531
|
+
renderHotspotList() {
|
|
1532
|
+
if (!this.hotspotList) return;
|
|
1533
|
+
|
|
1534
|
+
this.hotspotList.innerHTML = '';
|
|
1535
|
+
const hotspots = this.editor.hotspotEditor.getAllHotspots();
|
|
1536
|
+
const currentIndex = this.editor.hotspotEditor.currentHotspotIndex;
|
|
1537
|
+
|
|
1538
|
+
if (hotspots.length === 0) {
|
|
1539
|
+
const empty = document.createElement('div');
|
|
1540
|
+
empty.className = 'empty-state';
|
|
1541
|
+
empty.textContent = 'No hotspots. Click "Add Hotspot" to create one.';
|
|
1542
|
+
this.hotspotList.appendChild(empty);
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
hotspots.forEach((hotspot, index) => {
|
|
1547
|
+
const item = this.createHotspotItem(hotspot, index, index === currentIndex);
|
|
1548
|
+
this.hotspotList.appendChild(item);
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* Create hotspot list item
|
|
1554
|
+
*/
|
|
1555
|
+
createHotspotItem(hotspot, index, isActive) {
|
|
1556
|
+
const item = document.createElement('div');
|
|
1557
|
+
item.className = 'hotspot-item' + (isActive ? ' active' : '');
|
|
1558
|
+
|
|
1559
|
+
const color = document.createElement('div');
|
|
1560
|
+
color.className = 'hotspot-color';
|
|
1561
|
+
color.style.backgroundColor = hotspot.color;
|
|
1562
|
+
|
|
1563
|
+
const info = document.createElement('div');
|
|
1564
|
+
info.className = 'hotspot-info';
|
|
1565
|
+
|
|
1566
|
+
const title = document.createElement('div');
|
|
1567
|
+
title.className = 'hotspot-title';
|
|
1568
|
+
title.textContent = hotspot.title || 'Untitled Hotspot';
|
|
1569
|
+
|
|
1570
|
+
const target = document.createElement('div');
|
|
1571
|
+
target.className = 'hotspot-target';
|
|
1572
|
+
if (hotspot.targetSceneId) {
|
|
1573
|
+
const targetScene = this.editor.sceneManager.getSceneById(hotspot.targetSceneId);
|
|
1574
|
+
target.textContent = targetScene ? `→ ${targetScene.name}` : `→ ${hotspot.targetSceneId}`;
|
|
1575
|
+
} else {
|
|
1576
|
+
target.textContent = 'No target';
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
info.appendChild(title);
|
|
1580
|
+
info.appendChild(target);
|
|
1581
|
+
|
|
1582
|
+
const actions = document.createElement('div');
|
|
1583
|
+
actions.className = 'hotspot-actions';
|
|
1584
|
+
|
|
1585
|
+
const deleteBtn = document.createElement('button');
|
|
1586
|
+
deleteBtn.className = 'btn-delete';
|
|
1587
|
+
deleteBtn.innerHTML = '🗑️';
|
|
1588
|
+
deleteBtn.title = 'Delete';
|
|
1589
|
+
deleteBtn.onclick = (e) => {
|
|
1590
|
+
e.stopPropagation();
|
|
1591
|
+
this.editor.removeHotspot(index);
|
|
1592
|
+
};
|
|
1593
|
+
|
|
1594
|
+
actions.appendChild(deleteBtn);
|
|
1595
|
+
|
|
1596
|
+
item.appendChild(color);
|
|
1597
|
+
item.appendChild(info);
|
|
1598
|
+
item.appendChild(actions);
|
|
1599
|
+
|
|
1600
|
+
item.onclick = () => {
|
|
1601
|
+
this.editor.selectHotspot(index);
|
|
1602
|
+
};
|
|
1603
|
+
|
|
1604
|
+
return item;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* Update properties panel for hotspot
|
|
1609
|
+
*/
|
|
1610
|
+
updateHotspotProperties(hotspot) {
|
|
1611
|
+
const hotspotAll = document.getElementById('hotspotAll');
|
|
1612
|
+
const hotspotProperties = document.getElementById('hotspotProperties');
|
|
1613
|
+
|
|
1614
|
+
if (!hotspot) {
|
|
1615
|
+
// No hotspot selected - show list, hide properties
|
|
1616
|
+
if (hotspotAll) hotspotAll.style.display = 'block';
|
|
1617
|
+
if (hotspotProperties) hotspotProperties.style.display = 'none';
|
|
1618
|
+
|
|
1619
|
+
// Clear form
|
|
1620
|
+
document.getElementById('hotspotTitle').value = '';
|
|
1621
|
+
document.getElementById('hotspotDescription').value = '';
|
|
1622
|
+
document.getElementById('hotspotTarget').value = '';
|
|
1623
|
+
document.getElementById('hotspotColor').value = '#00ff00';
|
|
1624
|
+
const colorText = document.getElementById('hotspotColorText');
|
|
1625
|
+
if (colorText) colorText.value = '#00ff00';
|
|
1626
|
+
document.getElementById('hotspotPosX').value = '';
|
|
1627
|
+
document.getElementById('hotspotPosY').value = '';
|
|
1628
|
+
document.getElementById('hotspotPosZ').value = '';
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// Hotspot selected - show both list and properties
|
|
1633
|
+
if (hotspotAll) hotspotAll.style.display = 'block';
|
|
1634
|
+
if (hotspotProperties) hotspotProperties.style.display = 'block';
|
|
1635
|
+
|
|
1636
|
+
document.getElementById('hotspotTitle').value = hotspot.title || '';
|
|
1637
|
+
document.getElementById('hotspotDescription').value = hotspot.description || '';
|
|
1638
|
+
document.getElementById('hotspotTarget').value = hotspot.targetSceneId || '';
|
|
1639
|
+
document.getElementById('hotspotColor').value = hotspot.color || '#00ff00';
|
|
1640
|
+
|
|
1641
|
+
// Update color text input if it exists
|
|
1642
|
+
const colorText = document.getElementById('hotspotColorText');
|
|
1643
|
+
if (colorText) {
|
|
1644
|
+
colorText.value = hotspot.color || '#00ff00';
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// Update position inputs
|
|
1648
|
+
const pos = hotspot.position || { x: 0, y: 0, z: 0 };
|
|
1649
|
+
document.getElementById('hotspotPosX').value = pos.x;
|
|
1650
|
+
document.getElementById('hotspotPosY').value = pos.y;
|
|
1651
|
+
document.getElementById('hotspotPosZ').value = pos.z;
|
|
1652
|
+
|
|
1653
|
+
// Update target dropdown
|
|
1654
|
+
this.updateTargetSceneOptions();
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/**
|
|
1658
|
+
* Update properties panel for scene
|
|
1659
|
+
*/
|
|
1660
|
+
updateSceneProperties(scene) {
|
|
1661
|
+
if (!scene) {
|
|
1662
|
+
document.getElementById('sceneId').value = '';
|
|
1663
|
+
document.getElementById('sceneName').value = '';
|
|
1664
|
+
document.getElementById('sceneImageUrl').value = '';
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
document.getElementById('sceneId').value = scene.id || '';
|
|
1669
|
+
document.getElementById('sceneName').value = scene.name || '';
|
|
1670
|
+
document.getElementById('sceneImageUrl').value = scene.imageUrl || '';
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
/**
|
|
1674
|
+
* Update properties panel for tour
|
|
1675
|
+
*/
|
|
1676
|
+
updateTourProperties(config) {
|
|
1677
|
+
document.getElementById('tourTitle').value = config.title || '';
|
|
1678
|
+
document.getElementById('tourDescription').value = config.description || '';
|
|
1679
|
+
document.getElementById('tourInitialScene').value = config.initialSceneId || '';
|
|
1680
|
+
document.getElementById('tourAutoRotate').checked = config.autoRotate || false;
|
|
1681
|
+
document.getElementById('tourShowCompass').checked = config.showCompass || false;
|
|
1682
|
+
|
|
1683
|
+
// Also update project name in header if it exists
|
|
1684
|
+
const projectName = document.getElementById('project-name');
|
|
1685
|
+
if (projectName) {
|
|
1686
|
+
projectName.value = config.title || '';
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/**
|
|
1691
|
+
* Update target scene options in hotspot properties
|
|
1692
|
+
*/
|
|
1693
|
+
updateTargetSceneOptions() {
|
|
1694
|
+
const select = document.getElementById('hotspotTarget');
|
|
1695
|
+
if (!select) return;
|
|
1696
|
+
|
|
1697
|
+
const scenes = this.editor.sceneManager.getAllScenes();
|
|
1698
|
+
const currentValue = select.value;
|
|
1699
|
+
|
|
1700
|
+
select.innerHTML = '<option value="">Select target scene...</option>';
|
|
1701
|
+
|
|
1702
|
+
scenes.forEach(scene => {
|
|
1703
|
+
const option = document.createElement('option');
|
|
1704
|
+
option.value = scene.id;
|
|
1705
|
+
option.textContent = scene.name;
|
|
1706
|
+
select.appendChild(option);
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
select.value = currentValue;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/**
|
|
1713
|
+
* Update initial scene options in tour properties
|
|
1714
|
+
*/
|
|
1715
|
+
updateInitialSceneOptions() {
|
|
1716
|
+
const select = document.getElementById('tourInitialScene');
|
|
1717
|
+
if (!select) return;
|
|
1718
|
+
|
|
1719
|
+
const scenes = this.editor.sceneManager.getAllScenes();
|
|
1720
|
+
const currentValue = select.value;
|
|
1721
|
+
|
|
1722
|
+
select.innerHTML = '<option value="">Select initial scene...</option>';
|
|
1723
|
+
|
|
1724
|
+
scenes.forEach(scene => {
|
|
1725
|
+
const option = document.createElement('option');
|
|
1726
|
+
option.value = scene.id;
|
|
1727
|
+
option.textContent = scene.name;
|
|
1728
|
+
select.appendChild(option);
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
select.value = currentValue;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/**
|
|
1735
|
+
* Show/hide loading indicator
|
|
1736
|
+
*/
|
|
1737
|
+
setLoading(isLoading) {
|
|
1738
|
+
const indicator = document.querySelector('.loading-indicator');
|
|
1739
|
+
if (indicator) {
|
|
1740
|
+
indicator.style.display = isLoading ? 'block' : 'none';
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* Switch properties tab
|
|
1746
|
+
*/
|
|
1747
|
+
switchTab(tabName) {
|
|
1748
|
+
// Update tab buttons
|
|
1749
|
+
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
1750
|
+
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
// Update tab content
|
|
1754
|
+
document.querySelectorAll('.tab-content').forEach(content => {
|
|
1755
|
+
content.classList.toggle('active', content.id === tabName + 'Tab');
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
|
|
1760
|
+
// Export Manager - Handles JSON generation for SWT library
|
|
1761
|
+
|
|
1762
|
+
let ExportManager$1 = class ExportManager {
|
|
1763
|
+
constructor(editor) {
|
|
1764
|
+
this.editor = editor;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/**
|
|
1768
|
+
* Generate JSON compatible with SWT library
|
|
1769
|
+
*/
|
|
1770
|
+
generateJSON() {
|
|
1771
|
+
const scenes = this.editor.sceneManager.getAllScenes();
|
|
1772
|
+
const config = this.editor.config;
|
|
1773
|
+
// Build scenes array
|
|
1774
|
+
const scenesData = scenes.map(scene => ({
|
|
1775
|
+
id: scene.id,
|
|
1776
|
+
name: scene.name,
|
|
1777
|
+
imageUrl: scene.imageUrl,
|
|
1778
|
+
hotspots: scene.hotspots.map(hotspot => ({
|
|
1779
|
+
id: hotspot.id,
|
|
1780
|
+
type: hotspot.type || 'navigation',
|
|
1781
|
+
position: hotspot.position,
|
|
1782
|
+
targetSceneId: hotspot.targetSceneId || '',
|
|
1783
|
+
title: hotspot.title || '',
|
|
1784
|
+
description: hotspot.description || '',
|
|
1785
|
+
color: hotspot.color || '#00ff00',
|
|
1786
|
+
icon: hotspot.icon || ''
|
|
1787
|
+
}))
|
|
1788
|
+
}));
|
|
1789
|
+
// Determine initial scene
|
|
1790
|
+
let initialSceneId = config.initialSceneId;
|
|
1791
|
+
if (!initialSceneId && scenes.length > 0) {
|
|
1792
|
+
initialSceneId = scenes[0].id;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// Build final JSON
|
|
1796
|
+
const jsonData = {
|
|
1797
|
+
title: config.title || 'Virtual Tour',
|
|
1798
|
+
description: config.description || '',
|
|
1799
|
+
initialSceneId: initialSceneId,
|
|
1800
|
+
scenes: scenesData,
|
|
1801
|
+
settings: {
|
|
1802
|
+
autoRotate: config.autoRotate || false,
|
|
1803
|
+
showCompass: config.showCompass || false
|
|
1804
|
+
}
|
|
1805
|
+
};
|
|
1806
|
+
|
|
1807
|
+
return jsonData;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/**
|
|
1811
|
+
* Export as JSON file
|
|
1812
|
+
*/
|
|
1813
|
+
exportJSON() {
|
|
1814
|
+
try {
|
|
1815
|
+
const jsonData = this.generateJSON();
|
|
1816
|
+
const json = JSON.stringify(jsonData, null, 2);
|
|
1817
|
+
|
|
1818
|
+
const filename = sanitizeId(jsonData.title || 'tour') + '.json';
|
|
1819
|
+
downloadTextAsFile(json, filename);
|
|
1820
|
+
|
|
1821
|
+
showToast('Tour exported successfully', 'success');
|
|
1822
|
+
return true;
|
|
1823
|
+
} catch (error) {
|
|
1824
|
+
console.error('Export failed:', error);
|
|
1825
|
+
showToast('Export failed', 'error');
|
|
1826
|
+
return false;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/**
|
|
1831
|
+
* Copy JSON to clipboard
|
|
1832
|
+
*/
|
|
1833
|
+
async copyJSON() {
|
|
1834
|
+
try {
|
|
1835
|
+
const jsonData = this.generateJSON();
|
|
1836
|
+
const json = JSON.stringify(jsonData, null, 2);
|
|
1837
|
+
|
|
1838
|
+
const success = await copyToClipboard(json);
|
|
1839
|
+
if (success) {
|
|
1840
|
+
showToast('JSON copied to clipboard', 'success');
|
|
1841
|
+
} else {
|
|
1842
|
+
showToast('Failed to copy to clipboard', 'error');
|
|
1843
|
+
}
|
|
1844
|
+
return success;
|
|
1845
|
+
} catch (error) {
|
|
1846
|
+
console.error('Copy failed:', error);
|
|
1847
|
+
showToast('Copy failed', 'error');
|
|
1848
|
+
return false;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/**
|
|
1853
|
+
* Generate HTML viewer code
|
|
1854
|
+
*/
|
|
1855
|
+
generateViewerHTML() {
|
|
1856
|
+
const jsonData = this.generateJSON();
|
|
1857
|
+
|
|
1858
|
+
return `<!DOCTYPE html>
|
|
1859
|
+
<html lang="en">
|
|
1860
|
+
<head>
|
|
1861
|
+
<meta charset="UTF-8">
|
|
1862
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1863
|
+
<title>${jsonData.title}</title>
|
|
1864
|
+
<script src="https://aframe.io/releases/1.7.0/aframe.min.js"></script>
|
|
1865
|
+
<script src="dist/swt.min.js"></script>
|
|
1866
|
+
<style>
|
|
1867
|
+
body {
|
|
1868
|
+
margin: 0;
|
|
1869
|
+
overflow: hidden;
|
|
1870
|
+
font-family: Arial, sans-serif;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
#loading {
|
|
1874
|
+
position: fixed;
|
|
1875
|
+
top: 0;
|
|
1876
|
+
left: 0;
|
|
1877
|
+
width: 100%;
|
|
1878
|
+
height: 100%;
|
|
1879
|
+
background: #000;
|
|
1880
|
+
display: flex;
|
|
1881
|
+
align-items: center;
|
|
1882
|
+
justify-content: center;
|
|
1883
|
+
color: #fff;
|
|
1884
|
+
z-index: 1000;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
#loading.hidden {
|
|
1888
|
+
display: none;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
.spinner {
|
|
1892
|
+
border: 4px solid rgba(255,255,255,0.3);
|
|
1893
|
+
border-top: 4px solid #fff;
|
|
1894
|
+
border-radius: 50%;
|
|
1895
|
+
width: 40px;
|
|
1896
|
+
height: 40px;
|
|
1897
|
+
animation: spin 1s linear infinite;
|
|
1898
|
+
margin-right: 15px;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
@keyframes spin {
|
|
1902
|
+
0% { transform: rotate(0deg); }
|
|
1903
|
+
100% { transform: rotate(360deg); }
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
#ui {
|
|
1907
|
+
position: fixed;
|
|
1908
|
+
bottom: 20px;
|
|
1909
|
+
left: 50%;
|
|
1910
|
+
transform: translateX(-50%);
|
|
1911
|
+
z-index: 100;
|
|
1912
|
+
display: flex;
|
|
1913
|
+
gap: 10px;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
.btn {
|
|
1917
|
+
background: rgba(0,0,0,0.7);
|
|
1918
|
+
color: #fff;
|
|
1919
|
+
border: none;
|
|
1920
|
+
padding: 10px 20px;
|
|
1921
|
+
border-radius: 5px;
|
|
1922
|
+
cursor: pointer;
|
|
1923
|
+
font-size: 14px;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
.btn:hover {
|
|
1927
|
+
background: rgba(0,0,0,0.9);
|
|
1928
|
+
}
|
|
1929
|
+
</style>
|
|
1930
|
+
</head>
|
|
1931
|
+
<body>
|
|
1932
|
+
<div id="loading">
|
|
1933
|
+
<div class="spinner"></div>
|
|
1934
|
+
<span>Loading Tour...</span>
|
|
1935
|
+
</div>
|
|
1936
|
+
|
|
1937
|
+
<div id="tour-container"></div>
|
|
1938
|
+
|
|
1939
|
+
<div id="ui" style="display: none;">
|
|
1940
|
+
<button class="btn" id="resetBtn">Reset View</button>
|
|
1941
|
+
<span class="btn" id="sceneInfo"></span>
|
|
1942
|
+
</div>
|
|
1943
|
+
|
|
1944
|
+
<script>
|
|
1945
|
+
// Tour configuration
|
|
1946
|
+
const tourConfig = ${JSON.stringify(jsonData, null, 8)};
|
|
1947
|
+
|
|
1948
|
+
// Initialize tour
|
|
1949
|
+
let tour;
|
|
1950
|
+
|
|
1951
|
+
document.addEventListener('DOMContentLoaded', async () => {
|
|
1952
|
+
try {
|
|
1953
|
+
// Create tour instance
|
|
1954
|
+
tour = new SenangWebsTour('tour-container', tourConfig);
|
|
1955
|
+
|
|
1956
|
+
// Listen to events
|
|
1957
|
+
tour.on('sceneChanged', (sceneId) => {
|
|
1958
|
+
updateSceneInfo();
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
tour.on('ready', () => {
|
|
1962
|
+
document.getElementById('loading').classList.add('hidden');
|
|
1963
|
+
document.getElementById('ui').style.display = 'flex';
|
|
1964
|
+
updateSceneInfo();
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1967
|
+
tour.on('error', (error) => {
|
|
1968
|
+
console.error('Tour error:', error);
|
|
1969
|
+
alert('Failed to load tour: ' + error.message);
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1972
|
+
// Start tour
|
|
1973
|
+
await tour.start();
|
|
1974
|
+
|
|
1975
|
+
// Setup UI
|
|
1976
|
+
document.getElementById('resetBtn').addEventListener('click', () => {
|
|
1977
|
+
const camera = document.querySelector('[camera]');
|
|
1978
|
+
if (camera) {
|
|
1979
|
+
camera.setAttribute('rotation', '0 0 0');
|
|
1980
|
+
}
|
|
1981
|
+
});
|
|
1982
|
+
|
|
1983
|
+
} catch (error) {
|
|
1984
|
+
console.error('Failed to initialize tour:', error);
|
|
1985
|
+
alert('Failed to initialize tour: ' + error.message);
|
|
1986
|
+
}
|
|
1987
|
+
});
|
|
1988
|
+
|
|
1989
|
+
function updateSceneInfo() {
|
|
1990
|
+
const sceneId = tour.getCurrentSceneId();
|
|
1991
|
+
const scene = tourConfig.scenes.find(s => s.id === sceneId);
|
|
1992
|
+
if (scene) {
|
|
1993
|
+
document.getElementById('sceneInfo').textContent = scene.name;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
</script>
|
|
1997
|
+
</body>
|
|
1998
|
+
</html>`;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
/**
|
|
2002
|
+
* Export as standalone HTML viewer
|
|
2003
|
+
*/
|
|
2004
|
+
exportViewerHTML() {
|
|
2005
|
+
try {
|
|
2006
|
+
const html = this.generateViewerHTML();
|
|
2007
|
+
const jsonData = this.generateJSON();
|
|
2008
|
+
const filename = sanitizeId(jsonData.title || 'tour') + '-viewer.html';
|
|
2009
|
+
|
|
2010
|
+
downloadTextAsFile(html, filename);
|
|
2011
|
+
|
|
2012
|
+
showToast('Viewer HTML exported successfully', 'success');
|
|
2013
|
+
return true;
|
|
2014
|
+
} catch (error) {
|
|
2015
|
+
console.error('Export viewer failed:', error);
|
|
2016
|
+
showToast('Export viewer failed', 'error');
|
|
2017
|
+
return false;
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/**
|
|
2022
|
+
* Show export preview in modal
|
|
2023
|
+
*/
|
|
2024
|
+
showExportPreview() {
|
|
2025
|
+
try {
|
|
2026
|
+
const jsonData = this.generateJSON();
|
|
2027
|
+
const json = JSON.stringify(jsonData, null, 2);
|
|
2028
|
+
|
|
2029
|
+
const preview = document.getElementById('exportPreview');
|
|
2030
|
+
if (preview) {
|
|
2031
|
+
preview.textContent = json;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
showModal('exportModal');
|
|
2035
|
+
return true;
|
|
2036
|
+
} catch (error) {
|
|
2037
|
+
console.error('Failed to show export preview:', error);
|
|
2038
|
+
showToast('Failed to generate preview', 'error');
|
|
2039
|
+
return false;
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
};
|
|
2043
|
+
|
|
2044
|
+
// Main Editor Controller
|
|
2045
|
+
|
|
2046
|
+
let TourEditor$1 = class TourEditor {
|
|
2047
|
+
constructor(options = {}) {
|
|
2048
|
+
this.config = {
|
|
2049
|
+
title: options.projectName || 'My Virtual Tour',
|
|
2050
|
+
description: '',
|
|
2051
|
+
initialSceneId: '',
|
|
2052
|
+
autoRotate: false,
|
|
2053
|
+
showCompass: false
|
|
2054
|
+
};
|
|
2055
|
+
|
|
2056
|
+
// Store initialization options
|
|
2057
|
+
this.options = {
|
|
2058
|
+
sceneListElement: options.sceneListElement || null,
|
|
2059
|
+
previewElement: options.previewElement || null,
|
|
2060
|
+
propertiesElement: options.propertiesElement || null,
|
|
2061
|
+
autoSave: options.autoSave !== undefined ? options.autoSave : false,
|
|
2062
|
+
autoSaveInterval: options.autoSaveInterval || 30000,
|
|
2063
|
+
...options
|
|
2064
|
+
};
|
|
2065
|
+
|
|
2066
|
+
this.storageManager = new ProjectStorageManager();
|
|
2067
|
+
this.sceneManager = new SceneManagerEditor(this);
|
|
2068
|
+
this.hotspotEditor = new HotspotEditor(this);
|
|
2069
|
+
this.previewController = new PreviewController(this);
|
|
2070
|
+
this.uiController = new UIController(this);
|
|
2071
|
+
this.exportManager = new ExportManager(this);
|
|
2072
|
+
|
|
2073
|
+
this.hasUnsavedChanges = false;
|
|
2074
|
+
this.lastRenderedSceneIndex = -1;
|
|
2075
|
+
this.listenersSetup = false;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
/**
|
|
2079
|
+
* Initialize editor
|
|
2080
|
+
* @param {Object} config - Optional configuration object for programmatic init
|
|
2081
|
+
*/
|
|
2082
|
+
async init(config = {}) {
|
|
2083
|
+
// Merge config with existing options
|
|
2084
|
+
if (config && Object.keys(config).length > 0) {
|
|
2085
|
+
Object.assign(this.options, config);
|
|
2086
|
+
if (config.projectName) {
|
|
2087
|
+
this.config.title = config.projectName;
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// Initialize preview
|
|
2092
|
+
const previewInit = await this.previewController.init();
|
|
2093
|
+
if (!previewInit) {
|
|
2094
|
+
console.error('Failed to initialize preview controller');
|
|
2095
|
+
showToast('Failed to initialize preview', 'error');
|
|
2096
|
+
return false;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// Setup event listeners
|
|
2100
|
+
this.setupEventListeners();
|
|
2101
|
+
|
|
2102
|
+
// Load saved project if exists (but only if it has valid data)
|
|
2103
|
+
if (this.storageManager.hasProject()) {
|
|
2104
|
+
try {
|
|
2105
|
+
const projectData = this.storageManager.loadProject();
|
|
2106
|
+
if (projectData && projectData.scenes && projectData.scenes.length > 0) {
|
|
2107
|
+
this.loadProject();
|
|
2108
|
+
} else {
|
|
2109
|
+
// Invalid or empty project, clear it
|
|
2110
|
+
console.error('Invalid or empty project data, clearing storage');
|
|
2111
|
+
this.storageManager.clearProject();
|
|
2112
|
+
}
|
|
2113
|
+
} catch (error) {
|
|
2114
|
+
console.error('Error loading saved project:', error);
|
|
2115
|
+
this.storageManager.clearProject();
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
// Start auto-save if enabled
|
|
2120
|
+
if (this.options.autoSave) {
|
|
2121
|
+
this.storageManager.startAutoSave(() => {
|
|
2122
|
+
this.saveProject();
|
|
2123
|
+
}, this.options.autoSaveInterval);
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
// Initial render (only if no project was loaded)
|
|
2127
|
+
if (this.sceneManager.getScenes().length === 0) {
|
|
2128
|
+
this.render();
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
showToast('Editor ready', 'success');
|
|
2132
|
+
|
|
2133
|
+
return true;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
/**
|
|
2137
|
+
* Setup event listeners
|
|
2138
|
+
*/
|
|
2139
|
+
setupEventListeners() {
|
|
2140
|
+
if (this.listenersSetup) {
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
const addSceneBtns = document.querySelectorAll('#addSceneBtn');
|
|
2145
|
+
const sceneUploads = document.querySelectorAll('#sceneUpload');
|
|
2146
|
+
const importBtns = document.querySelectorAll('#importBtn');
|
|
2147
|
+
const importUploads = document.querySelectorAll('#importUpload');
|
|
2148
|
+
if (addSceneBtns.length > 1 || sceneUploads.length > 1 || importBtns.length > 1 || importUploads.length > 1) {
|
|
2149
|
+
console.error('Duplicate IDs found in DOM. This will cause double-trigger issues.');
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// Toolbar buttons
|
|
2153
|
+
document.getElementById('newBtn')?.addEventListener('click', () => this.newProject());
|
|
2154
|
+
document.getElementById('saveBtn')?.addEventListener('click', () => this.saveProject());
|
|
2155
|
+
document.getElementById('exportBtn')?.addEventListener('click', () => this.exportManager.showExportPreview());
|
|
2156
|
+
document.getElementById('importBtn')?.addEventListener('click', () => this.importProject());
|
|
2157
|
+
document.getElementById('helpBtn')?.addEventListener('click', () => showModal('helpModal'));
|
|
2158
|
+
|
|
2159
|
+
document.getElementById('addSceneBtn')?.addEventListener('click', () => {
|
|
2160
|
+
const sceneUpload = document.getElementById('sceneUpload');
|
|
2161
|
+
if (sceneUpload) {
|
|
2162
|
+
sceneUpload.click();
|
|
2163
|
+
}
|
|
2164
|
+
});
|
|
2165
|
+
|
|
2166
|
+
document.getElementById('sceneUpload')?.addEventListener('change', (e) => {
|
|
2167
|
+
if (e.target.files && e.target.files.length > 0) {
|
|
2168
|
+
this.handleSceneUpload(e.target.files);
|
|
2169
|
+
setTimeout(() => {
|
|
2170
|
+
e.target.value = '';
|
|
2171
|
+
}, 100);
|
|
2172
|
+
}
|
|
2173
|
+
});
|
|
2174
|
+
|
|
2175
|
+
document.getElementById('addHotspotBtn')?.addEventListener('click', () => {
|
|
2176
|
+
this.hotspotEditor.enablePlacementMode();
|
|
2177
|
+
});
|
|
2178
|
+
|
|
2179
|
+
document.getElementById('clearHotspotsBtn')?.addEventListener('click', () => {
|
|
2180
|
+
if (this.hotspotEditor.clearAllHotspots()) {
|
|
2181
|
+
this.render();
|
|
2182
|
+
}
|
|
2183
|
+
});
|
|
2184
|
+
|
|
2185
|
+
// Properties tabs
|
|
2186
|
+
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
2187
|
+
btn.addEventListener('click', () => {
|
|
2188
|
+
this.uiController.switchTab(btn.dataset.tab);
|
|
2189
|
+
});
|
|
2190
|
+
});
|
|
2191
|
+
|
|
2192
|
+
document.getElementById('hotspotTitle')?.addEventListener('input', debounce((e) => {
|
|
2193
|
+
this.updateCurrentHotspot('title', e.target.value);
|
|
2194
|
+
}, 300));
|
|
2195
|
+
|
|
2196
|
+
document.getElementById('hotspotDescription')?.addEventListener('input', debounce((e) => {
|
|
2197
|
+
this.updateCurrentHotspot('description', e.target.value);
|
|
2198
|
+
}, 300));
|
|
2199
|
+
|
|
2200
|
+
document.getElementById('hotspotTarget')?.addEventListener('change', (e) => {
|
|
2201
|
+
this.updateCurrentHotspot('targetSceneId', e.target.value);
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2204
|
+
document.getElementById('hotspotColor')?.addEventListener('input', (e) => {
|
|
2205
|
+
this.updateCurrentHotspot('color', e.target.value);
|
|
2206
|
+
});
|
|
2207
|
+
|
|
2208
|
+
document.getElementById('hotspotPosX')?.addEventListener('input', debounce((e) => {
|
|
2209
|
+
this.updateCurrentHotspotPosition('x', parseFloat(e.target.value) || 0);
|
|
2210
|
+
}, 300));
|
|
2211
|
+
|
|
2212
|
+
document.getElementById('hotspotPosY')?.addEventListener('input', debounce((e) => {
|
|
2213
|
+
this.updateCurrentHotspotPosition('y', parseFloat(e.target.value) || 0);
|
|
2214
|
+
}, 300));
|
|
2215
|
+
|
|
2216
|
+
document.getElementById('hotspotPosZ')?.addEventListener('input', debounce((e) => {
|
|
2217
|
+
this.updateCurrentHotspotPosition('z', parseFloat(e.target.value) || 0);
|
|
2218
|
+
}, 300));
|
|
2219
|
+
|
|
2220
|
+
document.getElementById('sceneId')?.addEventListener('input', debounce((e) => {
|
|
2221
|
+
this.updateCurrentScene('id', sanitizeId(e.target.value));
|
|
2222
|
+
}, 300));
|
|
2223
|
+
|
|
2224
|
+
document.getElementById('sceneName')?.addEventListener('input', debounce((e) => {
|
|
2225
|
+
this.updateCurrentScene('name', e.target.value);
|
|
2226
|
+
}, 300));
|
|
2227
|
+
|
|
2228
|
+
document.getElementById('sceneImageUrl')?.addEventListener('input', debounce((e) => {
|
|
2229
|
+
this.updateCurrentSceneImage(e.target.value);
|
|
2230
|
+
}, 300));
|
|
2231
|
+
|
|
2232
|
+
document.getElementById('tourTitle')?.addEventListener('input', debounce((e) => {
|
|
2233
|
+
this.config.title = e.target.value;
|
|
2234
|
+
this.markUnsavedChanges();
|
|
2235
|
+
const projectName = document.getElementById('project-name');
|
|
2236
|
+
if (projectName && projectName.value !== e.target.value) {
|
|
2237
|
+
projectName.value = e.target.value;
|
|
2238
|
+
}
|
|
2239
|
+
}, 300));
|
|
2240
|
+
|
|
2241
|
+
document.getElementById('project-name')?.addEventListener('input', debounce((e) => {
|
|
2242
|
+
this.config.title = e.target.value;
|
|
2243
|
+
this.markUnsavedChanges();
|
|
2244
|
+
const tourTitle = document.getElementById('tourTitle');
|
|
2245
|
+
if (tourTitle && tourTitle.value !== e.target.value) {
|
|
2246
|
+
tourTitle.value = e.target.value;
|
|
2247
|
+
}
|
|
2248
|
+
}, 300));
|
|
2249
|
+
|
|
2250
|
+
document.getElementById('tourDescription')?.addEventListener('input', debounce((e) => {
|
|
2251
|
+
this.config.description = e.target.value;
|
|
2252
|
+
this.markUnsavedChanges();
|
|
2253
|
+
}, 300));
|
|
2254
|
+
|
|
2255
|
+
document.getElementById('tourInitialScene')?.addEventListener('change', (e) => {
|
|
2256
|
+
this.config.initialSceneId = e.target.value;
|
|
2257
|
+
this.markUnsavedChanges();
|
|
2258
|
+
});
|
|
2259
|
+
|
|
2260
|
+
document.getElementById('tourAutoRotate')?.addEventListener('change', (e) => {
|
|
2261
|
+
this.config.autoRotate = e.target.checked;
|
|
2262
|
+
this.markUnsavedChanges();
|
|
2263
|
+
});
|
|
2264
|
+
|
|
2265
|
+
document.getElementById('tourShowCompass')?.addEventListener('change', (e) => {
|
|
2266
|
+
this.config.showCompass = e.target.checked;
|
|
2267
|
+
this.markUnsavedChanges();
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
document.getElementById('exportJsonBtn')?.addEventListener('click', () => {
|
|
2271
|
+
this.exportManager.exportJSON();
|
|
2272
|
+
});
|
|
2273
|
+
|
|
2274
|
+
document.getElementById('copyJsonBtn')?.addEventListener('click', () => {
|
|
2275
|
+
this.exportManager.copyJSON();
|
|
2276
|
+
});
|
|
2277
|
+
|
|
2278
|
+
document.getElementById('exportViewerBtn')?.addEventListener('click', () => {
|
|
2279
|
+
this.exportManager.exportViewerHTML();
|
|
2280
|
+
});
|
|
2281
|
+
|
|
2282
|
+
document.querySelectorAll('.modal-close').forEach(btn => {
|
|
2283
|
+
btn.addEventListener('click', () => {
|
|
2284
|
+
const modal = btn.closest('.modal');
|
|
2285
|
+
if (modal) {
|
|
2286
|
+
hideModal(modal.id);
|
|
2287
|
+
}
|
|
2288
|
+
});
|
|
2289
|
+
});
|
|
2290
|
+
|
|
2291
|
+
document.getElementById('importUpload')?.addEventListener('change', (e) => {
|
|
2292
|
+
if (e.target.files && e.target.files.length > 0) {
|
|
2293
|
+
this.handleImportFile(e.target.files[0]);
|
|
2294
|
+
setTimeout(() => {
|
|
2295
|
+
e.target.value = '';
|
|
2296
|
+
}, 100);
|
|
2297
|
+
}
|
|
2298
|
+
});
|
|
2299
|
+
|
|
2300
|
+
window.addEventListener('beforeunload', (e) => {
|
|
2301
|
+
if (this.hasUnsavedChanges) {
|
|
2302
|
+
e.preventDefault();
|
|
2303
|
+
e.returnValue = '';
|
|
2304
|
+
}
|
|
2305
|
+
});
|
|
2306
|
+
|
|
2307
|
+
this.listenersSetup = true;
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
/**
|
|
2311
|
+
* Handle scene upload
|
|
2312
|
+
*/
|
|
2313
|
+
async handleSceneUpload(files) {
|
|
2314
|
+
if (!files || files.length === 0) {
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
this.uiController.setLoading(true);
|
|
2319
|
+
|
|
2320
|
+
for (const file of files) {
|
|
2321
|
+
if (!file.type.startsWith('image/')) {
|
|
2322
|
+
showToast(`${file.name} is not an image`, 'error');
|
|
2323
|
+
continue;
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
await this.sceneManager.addScene(file);
|
|
2327
|
+
}
|
|
2328
|
+
this.uiController.setLoading(false);
|
|
2329
|
+
this.render();
|
|
2330
|
+
this.markUnsavedChanges();
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
/**
|
|
2334
|
+
* Add hotspot at position
|
|
2335
|
+
*/
|
|
2336
|
+
addHotspotAtPosition(position) {
|
|
2337
|
+
const distance = Math.sqrt(position.x * position.x + position.y * position.y + position.z * position.z);
|
|
2338
|
+
if (distance > 5) {
|
|
2339
|
+
const scale = 5 / distance;
|
|
2340
|
+
position.x *= scale;
|
|
2341
|
+
position.y *= scale;
|
|
2342
|
+
position.z *= scale;
|
|
2343
|
+
position.x = parseFloat(position.x.toFixed(2));
|
|
2344
|
+
position.y = parseFloat(position.y.toFixed(2));
|
|
2345
|
+
position.z = parseFloat(position.z.toFixed(2));
|
|
2346
|
+
}
|
|
2347
|
+
const hotspot = this.hotspotEditor.addHotspot(position);
|
|
2348
|
+
if (hotspot) {
|
|
2349
|
+
this.lastRenderedSceneIndex = -1;
|
|
2350
|
+
this.render();
|
|
2351
|
+
this.markUnsavedChanges();
|
|
2352
|
+
} else {
|
|
2353
|
+
console.error('Failed to add hotspot');
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
/**
|
|
2358
|
+
* Select scene by index
|
|
2359
|
+
*/
|
|
2360
|
+
selectScene(index) {
|
|
2361
|
+
if (this.sceneManager.setCurrentScene(index)) {
|
|
2362
|
+
this.lastRenderedSceneIndex = -1;
|
|
2363
|
+
this.hotspotEditor.currentHotspotIndex = -1;
|
|
2364
|
+
|
|
2365
|
+
const scene = this.sceneManager.getCurrentScene();
|
|
2366
|
+
if (scene) {
|
|
2367
|
+
this.previewController.loadScene(scene, false);
|
|
2368
|
+
this.lastRenderedSceneIndex = index;
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
this.uiController.renderSceneList();
|
|
2372
|
+
this.uiController.updateSceneProperties(scene);
|
|
2373
|
+
this.uiController.renderHotspotList();
|
|
2374
|
+
this.uiController.updateHotspotProperties(null);
|
|
2375
|
+
this.uiController.updateInitialSceneOptions();
|
|
2376
|
+
this.uiController.updateTargetSceneOptions();
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
/**
|
|
2381
|
+
* Select hotspot by index
|
|
2382
|
+
*/
|
|
2383
|
+
selectHotspot(index) {
|
|
2384
|
+
if (this.hotspotEditor.setCurrentHotspot(index)) {
|
|
2385
|
+
const hotspot = this.hotspotEditor.getHotspot(index);
|
|
2386
|
+
|
|
2387
|
+
this.uiController.renderHotspotList();
|
|
2388
|
+
this.uiController.updateHotspotProperties(hotspot);
|
|
2389
|
+
this.uiController.updateTargetSceneOptions();
|
|
2390
|
+
this.uiController.switchTab('hotspot');
|
|
2391
|
+
|
|
2392
|
+
if (hotspot && hotspot.position) {
|
|
2393
|
+
this.previewController.pointCameraToHotspot(hotspot.position);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
/**
|
|
2399
|
+
* Remove scene
|
|
2400
|
+
*/
|
|
2401
|
+
removeScene(index) {
|
|
2402
|
+
if (this.sceneManager.removeScene(index)) {
|
|
2403
|
+
this.render();
|
|
2404
|
+
this.markUnsavedChanges();
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
/**
|
|
2409
|
+
* Remove hotspot
|
|
2410
|
+
*/
|
|
2411
|
+
removeHotspot(index) {
|
|
2412
|
+
if (this.hotspotEditor.removeHotspot(index)) {
|
|
2413
|
+
this.lastRenderedSceneIndex = -1;
|
|
2414
|
+
this.render();
|
|
2415
|
+
this.markUnsavedChanges();
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
/**
|
|
2420
|
+
* Duplicate hotspot
|
|
2421
|
+
*/
|
|
2422
|
+
duplicateHotspot(index) {
|
|
2423
|
+
const hotspot = this.hotspotEditor.duplicateHotspot(index);
|
|
2424
|
+
if (hotspot) {
|
|
2425
|
+
this.lastRenderedSceneIndex = -1;
|
|
2426
|
+
this.render();
|
|
2427
|
+
this.markUnsavedChanges();
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
/**
|
|
2432
|
+
* Reorder scenes
|
|
2433
|
+
*/
|
|
2434
|
+
reorderScenes(fromIndex, toIndex) {
|
|
2435
|
+
if (this.sceneManager.reorderScenes(fromIndex, toIndex)) {
|
|
2436
|
+
this.render();
|
|
2437
|
+
this.markUnsavedChanges();
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
/**
|
|
2442
|
+
* Update current hotspot property
|
|
2443
|
+
*/
|
|
2444
|
+
async updateCurrentHotspot(property, value) {
|
|
2445
|
+
const index = this.hotspotEditor.currentHotspotIndex;
|
|
2446
|
+
if (this.hotspotEditor.updateHotspot(index, property, value)) {
|
|
2447
|
+
await this.previewController.updateHotspotMarker(index);
|
|
2448
|
+
this.uiController.renderHotspotList();
|
|
2449
|
+
this.markUnsavedChanges();
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
/**
|
|
2454
|
+
* Update current hotspot position (X, Y, or Z)
|
|
2455
|
+
*/
|
|
2456
|
+
async updateCurrentHotspotPosition(axis, value) {
|
|
2457
|
+
const index = this.hotspotEditor.currentHotspotIndex;
|
|
2458
|
+
const hotspot = this.hotspotEditor.getHotspot(index);
|
|
2459
|
+
|
|
2460
|
+
if (hotspot) {
|
|
2461
|
+
if (!hotspot.position) {
|
|
2462
|
+
hotspot.position = { x: 0, y: 0, z: 0 };
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
hotspot.position[axis] = value;
|
|
2466
|
+
|
|
2467
|
+
const pos = hotspot.position;
|
|
2468
|
+
const distance = Math.sqrt(pos.x * pos.x + pos.y * pos.y + pos.z * pos.z);
|
|
2469
|
+
if (distance > 10) {
|
|
2470
|
+
const scale = 10 / distance;
|
|
2471
|
+
pos.x *= scale;
|
|
2472
|
+
pos.y *= scale;
|
|
2473
|
+
pos.z *= scale;
|
|
2474
|
+
|
|
2475
|
+
document.getElementById(`hotspotPos${axis.toUpperCase()}`).value = pos[axis].toFixed(2);
|
|
2476
|
+
showToast('Position clamped to 10-unit radius', 'info');
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
await this.previewController.updateHotspotMarker(index);
|
|
2480
|
+
this.uiController.renderHotspotList();
|
|
2481
|
+
this.markUnsavedChanges();
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
/**
|
|
2486
|
+
* Update current scene property
|
|
2487
|
+
*/
|
|
2488
|
+
updateCurrentScene(property, value) {
|
|
2489
|
+
const index = this.sceneManager.currentSceneIndex;
|
|
2490
|
+
if (this.sceneManager.updateScene(index, property, value)) {
|
|
2491
|
+
this.uiController.renderSceneList();
|
|
2492
|
+
this.markUnsavedChanges();
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
/**
|
|
2497
|
+
* Update current scene image URL
|
|
2498
|
+
*/
|
|
2499
|
+
async updateCurrentSceneImage(imageUrl) {
|
|
2500
|
+
const index = this.sceneManager.currentSceneIndex;
|
|
2501
|
+
if (index < 0) return;
|
|
2502
|
+
|
|
2503
|
+
if (this.sceneManager.updateScene(index, 'imageUrl', imageUrl)) {
|
|
2504
|
+
const scene = this.sceneManager.getCurrentScene();
|
|
2505
|
+
if (scene) {
|
|
2506
|
+
scene.thumbnail = imageUrl;
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
this.uiController.renderSceneList();
|
|
2510
|
+
this.lastRenderedSceneIndex = -1;
|
|
2511
|
+
|
|
2512
|
+
if (scene) {
|
|
2513
|
+
await this.previewController.loadScene(scene);
|
|
2514
|
+
this.lastRenderedSceneIndex = index;
|
|
2515
|
+
showToast('Scene image updated', 'success');
|
|
2516
|
+
}
|
|
2517
|
+
this.markUnsavedChanges();
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
/**
|
|
2522
|
+
* Render all UI
|
|
2523
|
+
*/
|
|
2524
|
+
render() {
|
|
2525
|
+
this.uiController.renderSceneList();
|
|
2526
|
+
this.uiController.renderHotspotList();
|
|
2527
|
+
|
|
2528
|
+
const currentScene = this.sceneManager.getCurrentScene();
|
|
2529
|
+
const currentHotspot = this.hotspotEditor.getCurrentHotspot();
|
|
2530
|
+
|
|
2531
|
+
this.uiController.updateSceneProperties(currentScene);
|
|
2532
|
+
this.uiController.updateHotspotProperties(currentHotspot);
|
|
2533
|
+
this.uiController.updateTourProperties(this.config);
|
|
2534
|
+
this.uiController.updateInitialSceneOptions();
|
|
2535
|
+
this.uiController.updateTargetSceneOptions();
|
|
2536
|
+
|
|
2537
|
+
if (currentScene) {
|
|
2538
|
+
const emptyState = document.querySelector('.preview-empty');
|
|
2539
|
+
if (emptyState) {
|
|
2540
|
+
emptyState.style.display = 'none';
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
const currentSceneIndex = this.sceneManager.currentSceneIndex;
|
|
2544
|
+
if (currentSceneIndex !== this.lastRenderedSceneIndex) {
|
|
2545
|
+
this.previewController.loadScene(currentScene);
|
|
2546
|
+
this.lastRenderedSceneIndex = currentSceneIndex;
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
if (currentHotspot) {
|
|
2550
|
+
this.previewController.highlightHotspot(this.hotspotEditor.currentHotspotIndex);
|
|
2551
|
+
}
|
|
2552
|
+
} else {
|
|
2553
|
+
const emptyState = document.querySelector('.preview-empty');
|
|
2554
|
+
if (emptyState) {
|
|
2555
|
+
emptyState.style.display = 'flex';
|
|
2556
|
+
}
|
|
2557
|
+
this.lastRenderedSceneIndex = -1;
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
/**
|
|
2562
|
+
* Save project
|
|
2563
|
+
*/
|
|
2564
|
+
saveProject() {
|
|
2565
|
+
const projectData = {
|
|
2566
|
+
config: this.config,
|
|
2567
|
+
scenes: this.sceneManager.getAllScenes()
|
|
2568
|
+
};
|
|
2569
|
+
|
|
2570
|
+
if (this.storageManager.saveProject(projectData)) {
|
|
2571
|
+
this.hasUnsavedChanges = false;
|
|
2572
|
+
showToast('Project saved', 'success');
|
|
2573
|
+
return true;
|
|
2574
|
+
}
|
|
2575
|
+
return false;
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
/**
|
|
2579
|
+
* Load project
|
|
2580
|
+
*/
|
|
2581
|
+
loadProject() {
|
|
2582
|
+
const projectData = this.storageManager.loadProject();
|
|
2583
|
+
if (projectData) {
|
|
2584
|
+
this.config = projectData.config || this.config;
|
|
2585
|
+
this.sceneManager.loadScenes(projectData.scenes || []);
|
|
2586
|
+
this.hasUnsavedChanges = false;
|
|
2587
|
+
this.render();
|
|
2588
|
+
showToast('Project loaded', 'success');
|
|
2589
|
+
return true;
|
|
2590
|
+
}
|
|
2591
|
+
return false;
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
/**
|
|
2595
|
+
* New project
|
|
2596
|
+
*/
|
|
2597
|
+
newProject() {
|
|
2598
|
+
if (this.hasUnsavedChanges) {
|
|
2599
|
+
if (!confirm('You have unsaved changes. Create new project?')) {
|
|
2600
|
+
return false;
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
this.config = {
|
|
2605
|
+
title: 'My Virtual Tour',
|
|
2606
|
+
description: '',
|
|
2607
|
+
initialSceneId: '',
|
|
2608
|
+
autoRotate: false,
|
|
2609
|
+
showCompass: false
|
|
2610
|
+
};
|
|
2611
|
+
|
|
2612
|
+
this.sceneManager.clearScenes();
|
|
2613
|
+
this.hasUnsavedChanges = false;
|
|
2614
|
+
this.render();
|
|
2615
|
+
|
|
2616
|
+
showToast('New project created', 'success');
|
|
2617
|
+
return true;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
/**
|
|
2621
|
+
* Import project
|
|
2622
|
+
*/
|
|
2623
|
+
importProject() {
|
|
2624
|
+
const importUpload = document.getElementById('importUpload');
|
|
2625
|
+
if (importUpload) {
|
|
2626
|
+
importUpload.click();
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
/**
|
|
2631
|
+
* Handle import file
|
|
2632
|
+
*/
|
|
2633
|
+
async handleImportFile(file) {
|
|
2634
|
+
try {
|
|
2635
|
+
this.uiController.setLoading(true);
|
|
2636
|
+
|
|
2637
|
+
const projectData = await this.storageManager.importFromFile(file);
|
|
2638
|
+
|
|
2639
|
+
this.config = projectData.config || projectData;
|
|
2640
|
+
this.sceneManager.loadScenes(projectData.scenes || []);
|
|
2641
|
+
this.hasUnsavedChanges = true;
|
|
2642
|
+
|
|
2643
|
+
this.render();
|
|
2644
|
+
this.uiController.setLoading(false);
|
|
2645
|
+
|
|
2646
|
+
showToast('Project imported successfully', 'success');
|
|
2647
|
+
} catch (error) {
|
|
2648
|
+
this.uiController.setLoading(false);
|
|
2649
|
+
console.error('Import failed:', error);
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
/**
|
|
2654
|
+
* Mark unsaved changes
|
|
2655
|
+
*/
|
|
2656
|
+
markUnsavedChanges() {
|
|
2657
|
+
this.hasUnsavedChanges = true;
|
|
2658
|
+
}
|
|
2659
|
+
};
|
|
2660
|
+
|
|
2661
|
+
// Initialize editor when DOM is ready
|
|
2662
|
+
document.addEventListener('DOMContentLoaded', async () => {
|
|
2663
|
+
if (document.querySelector('[data-swt-editor]')) {
|
|
2664
|
+
// Declarative initialization will handle it
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
window.editor = new TourEditor$1();
|
|
2668
|
+
await window.editor.init();
|
|
2669
|
+
});
|
|
2670
|
+
|
|
2671
|
+
// UI Initialization - Handles color picker sync, keyboard shortcuts, tab switching, and declarative init
|
|
2672
|
+
|
|
2673
|
+
/**
|
|
2674
|
+
* Initialize editor from declarative HTML attributes
|
|
2675
|
+
*/
|
|
2676
|
+
function initDeclarativeEditor() {
|
|
2677
|
+
const editorElement = document.querySelector("[data-swt-editor]");
|
|
2678
|
+
|
|
2679
|
+
if (!editorElement) {
|
|
2680
|
+
return null; // No declarative editor found
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
// Check if auto-init is enabled
|
|
2684
|
+
const autoInit = editorElement.getAttribute("data-swt-auto-init");
|
|
2685
|
+
if (autoInit !== "true") {
|
|
2686
|
+
return null; // Auto-init disabled
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
// Find required elements by data attributes
|
|
2690
|
+
const sceneListElement = editorElement.querySelector("[data-swt-scene-list]");
|
|
2691
|
+
const previewElement = editorElement.querySelector("[data-swt-preview-area]");
|
|
2692
|
+
const propertiesElement = editorElement.querySelector(
|
|
2693
|
+
"[data-swt-properties-panel]"
|
|
2694
|
+
);
|
|
2695
|
+
|
|
2696
|
+
// Get optional configuration from attributes
|
|
2697
|
+
const projectName =
|
|
2698
|
+
editorElement.getAttribute("data-swt-project-name") || "My Virtual Tour";
|
|
2699
|
+
const autoSave = editorElement.getAttribute("data-swt-auto-save") === "true";
|
|
2700
|
+
const autoSaveInterval =
|
|
2701
|
+
parseInt(editorElement.getAttribute("data-swt-auto-save-interval")) ||
|
|
2702
|
+
30000;
|
|
2703
|
+
|
|
2704
|
+
// Create and initialize editor
|
|
2705
|
+
const editor = new TourEditor({
|
|
2706
|
+
projectName,
|
|
2707
|
+
autoSave,
|
|
2708
|
+
autoSaveInterval,
|
|
2709
|
+
});
|
|
2710
|
+
|
|
2711
|
+
// Store element references for controllers
|
|
2712
|
+
if (sceneListElement) editor.options.sceneListElement = sceneListElement;
|
|
2713
|
+
if (previewElement) editor.options.previewElement = previewElement;
|
|
2714
|
+
if (propertiesElement) editor.options.propertiesElement = propertiesElement;
|
|
2715
|
+
|
|
2716
|
+
editor.init().catch((err) => {
|
|
2717
|
+
console.error("Failed to initialize declarative editor:", err);
|
|
2718
|
+
});
|
|
2719
|
+
|
|
2720
|
+
return editor;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
window.addEventListener("DOMContentLoaded", () => {
|
|
2724
|
+
// Try declarative initialization first
|
|
2725
|
+
const declarativeEditor = initDeclarativeEditor();
|
|
2726
|
+
|
|
2727
|
+
if (declarativeEditor) {
|
|
2728
|
+
// Store editor instance globally for declarative mode
|
|
2729
|
+
window.editor = declarativeEditor;
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
// Setup color picker sync
|
|
2733
|
+
const colorPicker = document.getElementById("hotspotColor");
|
|
2734
|
+
const colorText = document.getElementById("hotspotColorText");
|
|
2735
|
+
|
|
2736
|
+
if (colorPicker && colorText) {
|
|
2737
|
+
colorPicker.addEventListener("input", (e) => {
|
|
2738
|
+
colorText.value = e.target.value;
|
|
2739
|
+
});
|
|
2740
|
+
|
|
2741
|
+
colorText.addEventListener("input", (e) => {
|
|
2742
|
+
if (/^#[0-9A-F]{6}$/i.test(e.target.value)) {
|
|
2743
|
+
colorPicker.value = e.target.value;
|
|
2744
|
+
}
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
// Keyboard shortcuts
|
|
2749
|
+
document.addEventListener("keydown", (e) => {
|
|
2750
|
+
// Ctrl/Cmd + S to save
|
|
2751
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
|
2752
|
+
e.preventDefault();
|
|
2753
|
+
if (window.editor) window.editor.saveProject();
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
// Ctrl/Cmd + E to export
|
|
2757
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "e") {
|
|
2758
|
+
e.preventDefault();
|
|
2759
|
+
if (window.editor) window.editor.exportManager.showExportPreview();
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
// ESC to close modals
|
|
2763
|
+
if (e.key === "Escape") {
|
|
2764
|
+
document.querySelectorAll(".modal.show").forEach((modal) => {
|
|
2765
|
+
modal.classList.remove("show");
|
|
2766
|
+
});
|
|
2767
|
+
}
|
|
2768
|
+
});
|
|
2769
|
+
|
|
2770
|
+
// Preview button - just focuses on the preview area
|
|
2771
|
+
const previewBtn = document.getElementById("previewBtn");
|
|
2772
|
+
if (previewBtn) {
|
|
2773
|
+
previewBtn.addEventListener("click", () => {
|
|
2774
|
+
const preview = document.getElementById("preview");
|
|
2775
|
+
const canvas = document.getElementById("canvasArea");
|
|
2776
|
+
if (preview && canvas) {
|
|
2777
|
+
canvas.classList.toggle("preview-active");
|
|
2778
|
+
// refresh preview
|
|
2779
|
+
if (window.editor && window.editor.previewController) {
|
|
2780
|
+
window.editor.previewController.refresh();
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
});
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
// Modal background click to close
|
|
2787
|
+
document.querySelectorAll(".modal").forEach((modal) => {
|
|
2788
|
+
modal.addEventListener("click", (e) => {
|
|
2789
|
+
if (e.target === modal) {
|
|
2790
|
+
modal.classList.remove("show");
|
|
2791
|
+
}
|
|
2792
|
+
});
|
|
2793
|
+
});
|
|
2794
|
+
|
|
2795
|
+
// Tab switching functionality
|
|
2796
|
+
const tabs = document.querySelectorAll(".tab");
|
|
2797
|
+
const tabContents = document.querySelectorAll(".tab-content");
|
|
2798
|
+
|
|
2799
|
+
tabs.forEach((tab) => {
|
|
2800
|
+
tab.addEventListener("click", () => {
|
|
2801
|
+
const targetTab = tab.dataset.tab;
|
|
2802
|
+
|
|
2803
|
+
// Update tab buttons
|
|
2804
|
+
tabs.forEach((t) => t.classList.remove("active"));
|
|
2805
|
+
tab.classList.add("active");
|
|
2806
|
+
|
|
2807
|
+
// Update tab content
|
|
2808
|
+
tabContents.forEach((content) => {
|
|
2809
|
+
content.style.display = "none";
|
|
2810
|
+
});
|
|
2811
|
+
|
|
2812
|
+
const targetContent = document.getElementById(targetTab + "Tab");
|
|
2813
|
+
if (targetContent) {
|
|
2814
|
+
targetContent.style.display = "block";
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
// Update panel title (if exists)
|
|
2818
|
+
const panelTitle = document.getElementById("panelTitle");
|
|
2819
|
+
if (panelTitle) {
|
|
2820
|
+
switch (targetTab) {
|
|
2821
|
+
case "scene":
|
|
2822
|
+
panelTitle.textContent = "Scene Properties";
|
|
2823
|
+
break;
|
|
2824
|
+
case "hotspot":
|
|
2825
|
+
panelTitle.textContent = "Hotspot Properties";
|
|
2826
|
+
break;
|
|
2827
|
+
case "tour":
|
|
2828
|
+
panelTitle.textContent = "Tour Settings";
|
|
2829
|
+
break;
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
});
|
|
2833
|
+
});
|
|
2834
|
+
});
|
|
2835
|
+
|
|
2836
|
+
// SenangWebs Tour Editor - Entry Point
|
|
2837
|
+
// This file bundles all editor modules for distribution
|
|
2838
|
+
|
|
2839
|
+
Object.assign(window, utils);
|
|
2840
|
+
|
|
2841
|
+
// Attach classes to window for global access
|
|
2842
|
+
window.ProjectStorageManager = ProjectStorageManager$1;
|
|
2843
|
+
window.SceneManagerEditor = SceneManagerEditor$1;
|
|
2844
|
+
window.HotspotEditor = HotspotEditor$1;
|
|
2845
|
+
window.PreviewController = PreviewController$1;
|
|
2846
|
+
window.UIController = UIController$1;
|
|
2847
|
+
window.ExportManager = ExportManager$1;
|
|
2848
|
+
window.TourEditor = TourEditor$1;
|
|
2849
|
+
|
|
2850
|
+
return TourEditor$1;
|
|
2851
|
+
|
|
2852
|
+
})();
|
|
2853
|
+
//# sourceMappingURL=swt-editor.js.map
|