senangwebs-tour 1.0.3 → 1.0.5
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/dist/swt-editor.css +36 -2
- package/dist/swt-editor.css.map +1 -1
- package/dist/swt-editor.js +3102 -209
- package/dist/swt-editor.js.map +1 -1
- package/dist/swt-editor.min.css +1 -1
- package/dist/swt-editor.min.js +1 -1
- package/dist/swt.js +292 -26
- package/dist/swt.js.map +1 -1
- package/dist/swt.min.js +1 -1
- package/package.json +2 -1
- package/src/AssetManager.js +13 -2
- package/src/HotspotManager.js +93 -20
- package/src/IconRenderer.js +123 -0
- package/src/SceneManager.js +7 -1
- package/src/editor/css/main.css +41 -2
- package/src/editor/js/editor.js +108 -23
- package/src/editor/js/export-manager.js +56 -9
- package/src/editor/js/hotspot-editor.js +10 -57
- package/src/editor/js/preview-controller.js +132 -105
- package/src/editor/js/ui-controller.js +91 -8
- package/src/editor/js/utils.js +1 -0
- package/src/index.js +56 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "senangwebs-tour",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "VR 360° virtual tour system with visual editor and viewer library",
|
|
6
6
|
"main": "dist/swt.js",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"author": "a-hakim",
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"devDependencies": {
|
|
27
|
+
"@rollup/plugin-json": "^6.1.0",
|
|
27
28
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
28
29
|
"@rollup/plugin-terser": "^0.4.4",
|
|
29
30
|
"@rollup/plugin-virtual": "^3.0.2",
|
package/src/AssetManager.js
CHANGED
|
@@ -56,8 +56,19 @@ export class AssetManager {
|
|
|
56
56
|
* @returns {Promise} - Resolves when the asset is loaded
|
|
57
57
|
*/
|
|
58
58
|
async preloadImage(url, id) {
|
|
59
|
-
if
|
|
60
|
-
|
|
59
|
+
// Check if asset exists and if the URL is the same
|
|
60
|
+
const existingAsset = this.loadedAssets.get(id);
|
|
61
|
+
if (existingAsset) {
|
|
62
|
+
const existingSrc = existingAsset.getAttribute('src');
|
|
63
|
+
if (existingSrc === url) {
|
|
64
|
+
// Same URL, return cached asset
|
|
65
|
+
return Promise.resolve(existingAsset);
|
|
66
|
+
}
|
|
67
|
+
// URL changed - remove old asset and create new one
|
|
68
|
+
if (existingAsset.parentNode) {
|
|
69
|
+
existingAsset.parentNode.removeChild(existingAsset);
|
|
70
|
+
}
|
|
71
|
+
this.loadedAssets.delete(id);
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
// Ensure assets element is ready
|
package/src/HotspotManager.js
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
* HotspotManager - Creates, manages, and removes hotspot entities in the A-Frame scene
|
|
3
3
|
*/
|
|
4
4
|
export class HotspotManager {
|
|
5
|
-
constructor(sceneEl, assetManager, defaultHotspotSettings = {}) {
|
|
5
|
+
constructor(sceneEl, assetManager, defaultHotspotSettings = {}, iconRenderer = null) {
|
|
6
6
|
this.sceneEl = sceneEl;
|
|
7
7
|
this.assetManager = assetManager;
|
|
8
8
|
this.defaultSettings = defaultHotspotSettings;
|
|
9
|
+
this.iconRenderer = iconRenderer;
|
|
9
10
|
this.activeHotspots = [];
|
|
10
11
|
this.tooltipEl = null;
|
|
11
12
|
this.tooltipCreated = false;
|
|
13
|
+
this.iconDataUrls = new Map(); // Cache for generated icon data URLs
|
|
14
|
+
this.sceneLoadCounter = 0; // Unique counter to prevent asset ID collisions between scene loads
|
|
15
|
+
this.currentAssetPrefix = ''; // Current asset ID prefix for this scene load
|
|
12
16
|
|
|
13
17
|
// Listen for hover events
|
|
14
18
|
this.sceneEl.addEventListener('swt-hotspot-hover', (evt) => {
|
|
@@ -87,20 +91,50 @@ export class HotspotManager {
|
|
|
87
91
|
this.createTooltip();
|
|
88
92
|
}
|
|
89
93
|
|
|
90
|
-
//
|
|
91
|
-
|
|
94
|
+
// Increment scene load counter to ensure unique asset IDs for this scene load
|
|
95
|
+
// This prevents A-Frame/THREE.js texture caching from reusing old icons
|
|
96
|
+
this.sceneLoadCounter++;
|
|
97
|
+
this.currentAssetPrefix = `hotspot-icon-${this.sceneLoadCounter}`;
|
|
98
|
+
|
|
99
|
+
// Clear previous icon data URLs cache
|
|
100
|
+
this.iconDataUrls.clear();
|
|
101
|
+
|
|
102
|
+
// Process all hotspot icons SEQUENTIALLY to avoid race condition
|
|
103
|
+
// (IconRenderer uses a shared renderContainer that would be corrupted by parallel generation)
|
|
104
|
+
for (let index = 0; index < hotspots.length; index++) {
|
|
105
|
+
const hotspot = hotspots[index];
|
|
92
106
|
const icon = hotspot.appearance?.icon || this.defaultSettings.icon;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
107
|
+
const color = hotspot.appearance?.color || '#ffffff';
|
|
108
|
+
|
|
109
|
+
if (!icon) continue;
|
|
110
|
+
|
|
111
|
+
// Check if it's an image URL
|
|
112
|
+
const isImageUrl = icon.startsWith('http') || icon.startsWith('data:') || icon.startsWith('/');
|
|
113
|
+
|
|
114
|
+
// Use unique asset ID that includes scene load counter
|
|
115
|
+
const assetId = `${this.currentAssetPrefix}-${index}`;
|
|
116
|
+
|
|
117
|
+
if (isImageUrl) {
|
|
118
|
+
// Preload as image asset
|
|
119
|
+
try {
|
|
120
|
+
await this.assetManager.preloadImage(icon, assetId);
|
|
121
|
+
} catch (err) {
|
|
96
122
|
console.warn(`Failed to load icon for hotspot ${index}, will use color instead`);
|
|
97
|
-
|
|
98
|
-
|
|
123
|
+
}
|
|
124
|
+
} else if (this.iconRenderer) {
|
|
125
|
+
// Generate icon data URL from SenangStart icon name
|
|
126
|
+
try {
|
|
127
|
+
const dataUrl = await this.iconRenderer.generateIconDataUrl(icon, color, 128);
|
|
128
|
+
if (dataUrl) {
|
|
129
|
+
this.iconDataUrls.set(index, dataUrl);
|
|
130
|
+
// Preload the generated data URL as an asset
|
|
131
|
+
await this.assetManager.preloadImage(dataUrl, assetId);
|
|
132
|
+
}
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.warn(`Failed to generate icon for hotspot ${index}:`, err);
|
|
135
|
+
}
|
|
99
136
|
}
|
|
100
|
-
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
await Promise.all(iconPromises);
|
|
137
|
+
}
|
|
104
138
|
|
|
105
139
|
// Then create the hotspot entities
|
|
106
140
|
hotspots.forEach((hotspot, index) => {
|
|
@@ -121,26 +155,31 @@ export class HotspotManager {
|
|
|
121
155
|
const pos = hotspot.position;
|
|
122
156
|
hotspotEl.setAttribute('position', `${pos.x} ${pos.y} ${pos.z}`);
|
|
123
157
|
|
|
124
|
-
//
|
|
158
|
+
// Get icon and color
|
|
125
159
|
const icon = hotspot.appearance?.icon || this.defaultSettings.icon;
|
|
126
|
-
const
|
|
127
|
-
|
|
160
|
+
const color = hotspot.appearance?.color || '#4CC3D9';
|
|
161
|
+
|
|
162
|
+
// Check if we have a preloaded icon asset (either from URL or generated from icon name)
|
|
163
|
+
// Use the unique asset prefix that includes scene load counter
|
|
164
|
+
const assetId = `${this.currentAssetPrefix}-${index}`;
|
|
165
|
+
const assetsEl = this.sceneEl.querySelector('a-assets');
|
|
166
|
+
const assetEl = assetsEl ? assetsEl.querySelector(`#${assetId}`) : null;
|
|
128
167
|
|
|
129
168
|
let visualEl;
|
|
130
169
|
|
|
131
|
-
// Check if icon was successfully loaded
|
|
170
|
+
// Check if icon asset was successfully loaded/generated
|
|
132
171
|
if (icon && assetEl) {
|
|
133
172
|
visualEl = document.createElement('a-image');
|
|
134
173
|
visualEl.setAttribute('src', `#${assetId}`);
|
|
135
174
|
// Make images double-sided
|
|
136
|
-
visualEl.setAttribute('material', 'side
|
|
175
|
+
visualEl.setAttribute('material', 'side: double; transparent: true; alphaTest: 0.1');
|
|
137
176
|
} else {
|
|
138
|
-
// Fallback to a plane
|
|
177
|
+
// Fallback to a colored plane
|
|
139
178
|
visualEl = document.createElement('a-plane');
|
|
140
|
-
visualEl.setAttribute('color',
|
|
179
|
+
visualEl.setAttribute('color', color);
|
|
141
180
|
visualEl.setAttribute('width', '1');
|
|
142
181
|
visualEl.setAttribute('height', '1');
|
|
143
|
-
// Make plane double-sided
|
|
182
|
+
// Make plane double-sided
|
|
144
183
|
visualEl.setAttribute('material', 'side', 'double');
|
|
145
184
|
}
|
|
146
185
|
|
|
@@ -168,6 +207,9 @@ export class HotspotManager {
|
|
|
168
207
|
* Remove all active hotspots from the scene
|
|
169
208
|
*/
|
|
170
209
|
removeAllHotspots() {
|
|
210
|
+
// Remove hotspot icon assets from a-assets to prevent icon mixup on scene navigation
|
|
211
|
+
this.removeHotspotIconAssets();
|
|
212
|
+
|
|
171
213
|
this.activeHotspots.forEach(hotspot => {
|
|
172
214
|
if (hotspot.parentNode) {
|
|
173
215
|
hotspot.parentNode.removeChild(hotspot);
|
|
@@ -175,12 +217,43 @@ export class HotspotManager {
|
|
|
175
217
|
});
|
|
176
218
|
this.activeHotspots = [];
|
|
177
219
|
|
|
220
|
+
// Clear icon data URLs cache
|
|
221
|
+
this.iconDataUrls.clear();
|
|
222
|
+
|
|
178
223
|
// Hide tooltip
|
|
179
224
|
if (this.tooltipEl) {
|
|
180
225
|
this.tooltipEl.setAttribute('visible', 'false');
|
|
181
226
|
}
|
|
182
227
|
}
|
|
183
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Remove all hotspot icon assets from a-assets element
|
|
231
|
+
* This prevents icon mixup when navigating between scenes
|
|
232
|
+
*/
|
|
233
|
+
removeHotspotIconAssets() {
|
|
234
|
+
const assetsEl = this.sceneEl.querySelector('a-assets');
|
|
235
|
+
if (!assetsEl) return;
|
|
236
|
+
|
|
237
|
+
// Find and remove all assets with IDs matching hotspot-icon-* pattern
|
|
238
|
+
const iconAssets = assetsEl.querySelectorAll('[id^="hotspot-icon-"]');
|
|
239
|
+
iconAssets.forEach(asset => {
|
|
240
|
+
if (asset.parentNode) {
|
|
241
|
+
asset.parentNode.removeChild(asset);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Also clear the asset manager's loaded assets for hotspot icons
|
|
246
|
+
if (this.assetManager && this.assetManager.loadedAssets) {
|
|
247
|
+
const keysToDelete = [];
|
|
248
|
+
this.assetManager.loadedAssets.forEach((value, key) => {
|
|
249
|
+
if (key.startsWith('hotspot-icon-')) {
|
|
250
|
+
keysToDelete.push(key);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
keysToDelete.forEach(key => this.assetManager.loadedAssets.delete(key));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
184
257
|
/**
|
|
185
258
|
* Clean up the hotspot manager
|
|
186
259
|
*/
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IconRenderer - Converts SenangStart icon names to image data URLs for A-Frame
|
|
3
|
+
*/
|
|
4
|
+
export class IconRenderer {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.iconCache = new Map();
|
|
7
|
+
this.renderContainer = null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Initialize the hidden render container
|
|
12
|
+
*/
|
|
13
|
+
init() {
|
|
14
|
+
if (this.renderContainer) return;
|
|
15
|
+
|
|
16
|
+
this.renderContainer = document.createElement('div');
|
|
17
|
+
this.renderContainer.id = 'swt-icon-renderer';
|
|
18
|
+
this.renderContainer.style.cssText = `
|
|
19
|
+
position: absolute;
|
|
20
|
+
left: -9999px;
|
|
21
|
+
top: -9999px;
|
|
22
|
+
width: 128px;
|
|
23
|
+
height: 128px;
|
|
24
|
+
pointer-events: none;
|
|
25
|
+
visibility: hidden;
|
|
26
|
+
`;
|
|
27
|
+
document.body.appendChild(this.renderContainer);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generate an image data URL from a SenangStart icon
|
|
32
|
+
* @param {string} iconName - SenangStart icon name (e.g., 'arrow-right')
|
|
33
|
+
* @param {string} color - Hex color for the icon
|
|
34
|
+
* @param {number} size - Size in pixels (default 128)
|
|
35
|
+
* @returns {Promise<string>} - Data URL of the icon image
|
|
36
|
+
*/
|
|
37
|
+
async generateIconDataUrl(iconName, color = '#ffffff', size = 128) {
|
|
38
|
+
const cacheKey = `${iconName}-${color}-${size}`;
|
|
39
|
+
|
|
40
|
+
if (this.iconCache.has(cacheKey)) {
|
|
41
|
+
return this.iconCache.get(cacheKey);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.init();
|
|
45
|
+
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
// Create the ss-icon element
|
|
48
|
+
this.renderContainer.innerHTML = `
|
|
49
|
+
<ss-icon
|
|
50
|
+
icon="${iconName}"
|
|
51
|
+
thickness="2.5"
|
|
52
|
+
style="color: ${color}; width: ${size}px; height: ${size}px; display: block;"
|
|
53
|
+
></ss-icon>
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
// Wait for the custom element to render
|
|
57
|
+
setTimeout(() => {
|
|
58
|
+
try {
|
|
59
|
+
const ssIcon = this.renderContainer.querySelector('ss-icon');
|
|
60
|
+
if (!ssIcon || !ssIcon.shadowRoot) {
|
|
61
|
+
console.warn(`Icon ${iconName} not rendered properly`);
|
|
62
|
+
resolve(null);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Get the SVG from shadow root
|
|
67
|
+
const svg = ssIcon.shadowRoot.querySelector('svg');
|
|
68
|
+
if (!svg) {
|
|
69
|
+
console.warn(`SVG not found for icon ${iconName}`);
|
|
70
|
+
resolve(null);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Clone and prepare SVG
|
|
75
|
+
const svgClone = svg.cloneNode(true);
|
|
76
|
+
svgClone.setAttribute('width', size);
|
|
77
|
+
svgClone.setAttribute('height', size);
|
|
78
|
+
svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
79
|
+
|
|
80
|
+
// Apply the color to all paths/elements
|
|
81
|
+
svgClone.querySelectorAll('path, circle, rect, line, polyline, polygon').forEach(el => {
|
|
82
|
+
el.setAttribute('stroke', color);
|
|
83
|
+
// Keep fill as currentColor if it's set
|
|
84
|
+
const fill = el.getAttribute('fill');
|
|
85
|
+
if (fill && fill !== 'none') {
|
|
86
|
+
el.setAttribute('fill', color);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Convert SVG to data URL
|
|
91
|
+
const svgString = new XMLSerializer().serializeToString(svgClone);
|
|
92
|
+
const dataUrl = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgString)));
|
|
93
|
+
|
|
94
|
+
// Cache the result
|
|
95
|
+
this.iconCache.set(cacheKey, dataUrl);
|
|
96
|
+
|
|
97
|
+
resolve(dataUrl);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.error('Error generating icon data URL:', error);
|
|
100
|
+
resolve(null);
|
|
101
|
+
}
|
|
102
|
+
}, 100); // Wait for custom element to render
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Clear the icon cache
|
|
108
|
+
*/
|
|
109
|
+
clearCache() {
|
|
110
|
+
this.iconCache.clear();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Destroy the renderer
|
|
115
|
+
*/
|
|
116
|
+
destroy() {
|
|
117
|
+
if (this.renderContainer && this.renderContainer.parentNode) {
|
|
118
|
+
this.renderContainer.parentNode.removeChild(this.renderContainer);
|
|
119
|
+
}
|
|
120
|
+
this.renderContainer = null;
|
|
121
|
+
this.iconCache.clear();
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/SceneManager.js
CHANGED
|
@@ -62,15 +62,21 @@ export class SceneManager {
|
|
|
62
62
|
* Transition to a new scene with fade effect
|
|
63
63
|
* @param {string} sceneId - The target scene ID
|
|
64
64
|
* @param {Object} sceneData - The scene configuration object
|
|
65
|
+
* @param {Function} onSceneLoaded - Optional callback to run after scene loads but before fade-in
|
|
65
66
|
* @returns {Promise} - Resolves when the transition is complete
|
|
66
67
|
*/
|
|
67
|
-
async transitionTo(sceneId, sceneData) {
|
|
68
|
+
async transitionTo(sceneId, sceneData, onSceneLoaded = null) {
|
|
68
69
|
// Fade out
|
|
69
70
|
await this.fadeOut();
|
|
70
71
|
|
|
71
72
|
// Load new scene
|
|
72
73
|
await this.loadScene(sceneId, sceneData);
|
|
73
74
|
|
|
75
|
+
// Call onSceneLoaded callback (e.g., to set camera position while screen is black)
|
|
76
|
+
if (onSceneLoaded && typeof onSceneLoaded === 'function') {
|
|
77
|
+
onSceneLoaded();
|
|
78
|
+
}
|
|
79
|
+
|
|
74
80
|
// Fade in
|
|
75
81
|
await this.fadeIn();
|
|
76
82
|
|
package/src/editor/css/main.css
CHANGED
|
@@ -223,7 +223,6 @@ body {
|
|
|
223
223
|
|
|
224
224
|
.scene-card.active {
|
|
225
225
|
border-color: var(--accent-primary);
|
|
226
|
-
background: rgba(59, 130, 246, 0.1);
|
|
227
226
|
}
|
|
228
227
|
|
|
229
228
|
.scene-thumbnail {
|
|
@@ -677,7 +676,6 @@ body {
|
|
|
677
676
|
|
|
678
677
|
.hotspot-item.active {
|
|
679
678
|
border-color: var(--accent-primary);
|
|
680
|
-
background: rgba(59, 130, 246, 0.1);
|
|
681
679
|
}
|
|
682
680
|
|
|
683
681
|
.hotspot-color {
|
|
@@ -1020,6 +1018,47 @@ body {
|
|
|
1020
1018
|
}
|
|
1021
1019
|
}
|
|
1022
1020
|
|
|
1021
|
+
/* Icon Grid */
|
|
1022
|
+
.icon-grid {
|
|
1023
|
+
display: grid;
|
|
1024
|
+
grid-template-columns: repeat(5, 1fr);
|
|
1025
|
+
gap: 8px;
|
|
1026
|
+
margin-top: 8px;
|
|
1027
|
+
height: 280px;
|
|
1028
|
+
overflow-y: auto;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/* Icon Grid Buttons */
|
|
1032
|
+
.icon-btn {
|
|
1033
|
+
width: 100%;
|
|
1034
|
+
aspect-ratio: 1;
|
|
1035
|
+
border: 2px solid var(--border-color);
|
|
1036
|
+
border-radius: 6px;
|
|
1037
|
+
background: var(--bg-secondary);
|
|
1038
|
+
cursor: pointer;
|
|
1039
|
+
display: flex;
|
|
1040
|
+
align-items: center;
|
|
1041
|
+
justify-content: center;
|
|
1042
|
+
transition: all 0.2s ease;
|
|
1043
|
+
color: var(--text-secondary);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
.icon-btn ss-icon {
|
|
1047
|
+
width: 20px;
|
|
1048
|
+
height: 20px;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
.icon-btn:hover {
|
|
1052
|
+
background: var(--bg-hover);
|
|
1053
|
+
border-color: var(--text-muted);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
.icon-btn.active {
|
|
1057
|
+
border-color: var(--accent-primary);
|
|
1058
|
+
background: rgba(0, 255, 153, 0.1);
|
|
1059
|
+
color: var(--accent-primary);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1023
1062
|
/* Responsive */
|
|
1024
1063
|
@media (max-width: 1024px) {
|
|
1025
1064
|
.sidebar {
|
package/src/editor/js/editor.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
// Main Editor Controller
|
|
2
|
-
import { debounce, showModal } from './utils.js';
|
|
2
|
+
import { debounce, showModal, showToast } from './utils.js';
|
|
3
3
|
|
|
4
4
|
class TourEditor {
|
|
5
5
|
constructor(options = {}) {
|
|
6
6
|
this.config = {
|
|
7
7
|
title: options.projectName || 'My Virtual Tour',
|
|
8
8
|
description: '',
|
|
9
|
-
initialSceneId: ''
|
|
10
|
-
autoRotate: false,
|
|
11
|
-
showCompass: false
|
|
9
|
+
initialSceneId: ''
|
|
12
10
|
};
|
|
13
11
|
|
|
14
12
|
// Store initialization options
|
|
@@ -57,6 +55,9 @@ class TourEditor {
|
|
|
57
55
|
// Setup event listeners
|
|
58
56
|
this.setupEventListeners();
|
|
59
57
|
|
|
58
|
+
// Populate icon grid
|
|
59
|
+
this.uiController.populateIconGrid();
|
|
60
|
+
|
|
60
61
|
// Load saved project if exists (but only if it has valid data)
|
|
61
62
|
if (this.storageManager.hasProject()) {
|
|
62
63
|
try {
|
|
@@ -131,7 +132,7 @@ class TourEditor {
|
|
|
131
132
|
});
|
|
132
133
|
|
|
133
134
|
document.getElementById('addHotspotBtn')?.addEventListener('click', () => {
|
|
134
|
-
this.
|
|
135
|
+
this.addHotspotAtCursor();
|
|
135
136
|
});
|
|
136
137
|
|
|
137
138
|
document.getElementById('clearHotspotsBtn')?.addEventListener('click', () => {
|
|
@@ -163,6 +164,19 @@ class TourEditor {
|
|
|
163
164
|
this.updateCurrentHotspot('color', e.target.value);
|
|
164
165
|
});
|
|
165
166
|
|
|
167
|
+
// Icon grid button clicks
|
|
168
|
+
document.getElementById('hotspotIconGrid')?.addEventListener('click', (e) => {
|
|
169
|
+
const btn = e.target.closest('.icon-btn');
|
|
170
|
+
if (btn) {
|
|
171
|
+
const iconValue = btn.dataset.icon;
|
|
172
|
+
// Update active state
|
|
173
|
+
document.querySelectorAll('#hotspotIconGrid .icon-btn').forEach(b => b.classList.remove('active'));
|
|
174
|
+
btn.classList.add('active');
|
|
175
|
+
// Update hotspot
|
|
176
|
+
this.updateCurrentHotspot('icon', iconValue);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
166
180
|
document.getElementById('hotspotPosX')?.addEventListener('input', debounce((e) => {
|
|
167
181
|
this.updateCurrentHotspotPosition('x', parseFloat(e.target.value) || 0);
|
|
168
182
|
}, 300));
|
|
@@ -187,6 +201,14 @@ class TourEditor {
|
|
|
187
201
|
this.updateCurrentSceneImage(e.target.value);
|
|
188
202
|
}, 300));
|
|
189
203
|
|
|
204
|
+
document.getElementById('setStartingPosBtn')?.addEventListener('click', () => {
|
|
205
|
+
this.setSceneStartingPosition();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
document.getElementById('clearStartingPosBtn')?.addEventListener('click', () => {
|
|
209
|
+
this.clearSceneStartingPosition();
|
|
210
|
+
});
|
|
211
|
+
|
|
190
212
|
document.getElementById('tourTitle')?.addEventListener('input', debounce((e) => {
|
|
191
213
|
this.config.title = e.target.value;
|
|
192
214
|
this.markUnsavedChanges();
|
|
@@ -214,16 +236,6 @@ class TourEditor {
|
|
|
214
236
|
this.config.initialSceneId = e.target.value;
|
|
215
237
|
this.markUnsavedChanges();
|
|
216
238
|
});
|
|
217
|
-
|
|
218
|
-
document.getElementById('tourAutoRotate')?.addEventListener('change', (e) => {
|
|
219
|
-
this.config.autoRotate = e.target.checked;
|
|
220
|
-
this.markUnsavedChanges();
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
document.getElementById('tourShowCompass')?.addEventListener('change', (e) => {
|
|
224
|
-
this.config.showCompass = e.target.checked;
|
|
225
|
-
this.markUnsavedChanges();
|
|
226
|
-
});
|
|
227
239
|
|
|
228
240
|
document.getElementById('exportJsonBtn')?.addEventListener('click', () => {
|
|
229
241
|
this.exportManager.exportJSON();
|
|
@@ -233,8 +245,8 @@ class TourEditor {
|
|
|
233
245
|
this.exportManager.copyJSON();
|
|
234
246
|
});
|
|
235
247
|
|
|
236
|
-
document.getElementById('exportViewerBtn')?.addEventListener('click', () => {
|
|
237
|
-
this.exportManager.exportViewerHTML();
|
|
248
|
+
document.getElementById('exportViewerBtn')?.addEventListener('click', async () => {
|
|
249
|
+
await this.exportManager.exportViewerHTML();
|
|
238
250
|
});
|
|
239
251
|
|
|
240
252
|
document.querySelectorAll('.modal-close').forEach(btn => {
|
|
@@ -302,7 +314,15 @@ class TourEditor {
|
|
|
302
314
|
position.y = parseFloat(position.y.toFixed(2));
|
|
303
315
|
position.z = parseFloat(position.z.toFixed(2));
|
|
304
316
|
}
|
|
305
|
-
|
|
317
|
+
|
|
318
|
+
// Capture current camera orientation for reliable pointing later
|
|
319
|
+
const cameraRotation = this.previewController.getCameraRotation();
|
|
320
|
+
const cameraOrientation = cameraRotation ? {
|
|
321
|
+
pitch: cameraRotation.x,
|
|
322
|
+
yaw: cameraRotation.y
|
|
323
|
+
} : null;
|
|
324
|
+
|
|
325
|
+
const hotspot = this.hotspotEditor.addHotspot(position, '', cameraOrientation);
|
|
306
326
|
if (hotspot) {
|
|
307
327
|
this.lastRenderedSceneIndex = -1;
|
|
308
328
|
this.render();
|
|
@@ -312,6 +332,26 @@ class TourEditor {
|
|
|
312
332
|
}
|
|
313
333
|
}
|
|
314
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Add hotspot at current cursor position (center of view)
|
|
337
|
+
* This uses the A-Cursor's raycaster intersection with the sky sphere
|
|
338
|
+
*/
|
|
339
|
+
addHotspotAtCursor() {
|
|
340
|
+
const scene = this.sceneManager.getCurrentScene();
|
|
341
|
+
if (!scene) {
|
|
342
|
+
showToast('Please select a scene first', 'error');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const position = this.previewController.getCursorIntersection();
|
|
347
|
+
if (!position) {
|
|
348
|
+
showToast('Could not get cursor position. Please ensure the preview is loaded.', 'error');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
this.addHotspotAtPosition(position);
|
|
353
|
+
}
|
|
354
|
+
|
|
315
355
|
/**
|
|
316
356
|
* Select scene by index
|
|
317
357
|
*/
|
|
@@ -347,8 +387,8 @@ class TourEditor {
|
|
|
347
387
|
this.uiController.updateTargetSceneOptions();
|
|
348
388
|
this.uiController.switchTab('hotspot');
|
|
349
389
|
|
|
350
|
-
if (hotspot
|
|
351
|
-
this.previewController.pointCameraToHotspot(hotspot
|
|
390
|
+
if (hotspot) {
|
|
391
|
+
this.previewController.pointCameraToHotspot(hotspot);
|
|
352
392
|
}
|
|
353
393
|
}
|
|
354
394
|
}
|
|
@@ -422,6 +462,10 @@ class TourEditor {
|
|
|
422
462
|
|
|
423
463
|
hotspot.position[axis] = value;
|
|
424
464
|
|
|
465
|
+
// Clear camera orientation since position changed manually
|
|
466
|
+
// Will fallback to position-based calculation when pointing camera
|
|
467
|
+
hotspot.cameraOrientation = null;
|
|
468
|
+
|
|
425
469
|
const pos = hotspot.position;
|
|
426
470
|
const distance = Math.sqrt(pos.x * pos.x + pos.y * pos.y + pos.z * pos.z);
|
|
427
471
|
if (distance > 10) {
|
|
@@ -476,6 +520,49 @@ class TourEditor {
|
|
|
476
520
|
}
|
|
477
521
|
}
|
|
478
522
|
|
|
523
|
+
/**
|
|
524
|
+
* Set scene starting position to current camera rotation
|
|
525
|
+
*/
|
|
526
|
+
setSceneStartingPosition() {
|
|
527
|
+
const scene = this.sceneManager.getCurrentScene();
|
|
528
|
+
if (!scene) {
|
|
529
|
+
showToast('No scene selected', 'error');
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const rotation = this.previewController.getCameraRotation();
|
|
534
|
+
if (!rotation) {
|
|
535
|
+
showToast('Could not get camera rotation', 'error');
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
scene.startingPosition = {
|
|
540
|
+
pitch: rotation.x,
|
|
541
|
+
yaw: rotation.y
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
this.uiController.updateSceneProperties(scene);
|
|
545
|
+
this.markUnsavedChanges();
|
|
546
|
+
showToast('Starting position set', 'success');
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Clear scene starting position
|
|
551
|
+
*/
|
|
552
|
+
clearSceneStartingPosition() {
|
|
553
|
+
const scene = this.sceneManager.getCurrentScene();
|
|
554
|
+
if (!scene) {
|
|
555
|
+
showToast('No scene selected', 'error');
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
scene.startingPosition = null;
|
|
560
|
+
|
|
561
|
+
this.uiController.updateSceneProperties(scene);
|
|
562
|
+
this.markUnsavedChanges();
|
|
563
|
+
showToast('Starting position cleared', 'success');
|
|
564
|
+
}
|
|
565
|
+
|
|
479
566
|
/**
|
|
480
567
|
* Render all UI
|
|
481
568
|
*/
|
|
@@ -562,9 +649,7 @@ class TourEditor {
|
|
|
562
649
|
this.config = {
|
|
563
650
|
title: 'My Virtual Tour',
|
|
564
651
|
description: '',
|
|
565
|
-
initialSceneId: ''
|
|
566
|
-
autoRotate: false,
|
|
567
|
-
showCompass: false
|
|
652
|
+
initialSceneId: ''
|
|
568
653
|
};
|
|
569
654
|
|
|
570
655
|
this.sceneManager.clearScenes();
|