quackscape/assets/js/editor.js

2313 lines
No EOL
72 KiB
JavaScript

import { getScene, getSceneElement, getCategory, getCookie } from "./api";
import { populateDestinationDropdown } from "./editor/teleport";
import { showNotification } from "./utils/notifications";
import registerClickDrag from "@kumitterer/aframe-click-drag-component";
import "@phosphor-icons/web/light";
import "../css/editor.css";
// Register A-Frame components
registerClickDrag(window.aframe);
// Editor state
const editorState = {
activeElement: null,
editMode: "select", // select, marker, image, teleport
isDragging: false,
clickTimestamp: 0,
scene: null,
modified: false,
originalData: null,
currentPosition: null // Used to track the position of a dragged element
};
// Global variables for drag state
const dragState = {
isDragging: false,
element: null,
lastMouseX: 0,
lastMouseY: 0,
radius: 5
};
// Initialize editor when DOM is loaded
document.addEventListener("DOMContentLoaded", initEditor);
// Initialize editor
function initEditor() {
console.log("Initializing editor...");
// Set up tool buttons
setupToolButtons();
// Set up save button
const saveBtn = document.getElementById("saveBtn");
if (saveBtn) {
saveBtn.addEventListener("click", saveChanges);
} else {
console.warn("Save button not found");
}
// Set up preview button
const previewBtn = document.getElementById("previewBtn");
if (previewBtn) {
previewBtn.addEventListener("click", previewScene);
} else {
console.warn("Preview button not found");
}
// Set up reset view button
const resetViewBtn = document.getElementById("resetViewBtn");
if (resetViewBtn) {
resetViewBtn.addEventListener("click", resetView);
} else {
console.warn("Reset view button not found");
}
// Set up fullscreen button
const fullscreenBtn = document.getElementById("fullscreenBtn");
if (fullscreenBtn) {
fullscreenBtn.addEventListener("click", toggleFullscreen);
} else {
console.warn("Fullscreen button not found");
}
// Set up settings form submission
const saveSettingsBtn = document.getElementById("saveSettingsBtn");
if (saveSettingsBtn) {
saveSettingsBtn.addEventListener("click", saveSceneSettings);
} else {
console.warn("Save settings button not found");
}
// Set up Add Element dropdown
setupAddElementDropdown();
// Listen for scene loaded event
document.addEventListener("loadedQuackscapeScene", handleSceneLoaded);
// Listen for tab changes
setupTabListeners();
// Set up custom position handling for direct input
setupDirectPositionHandling();
// Set up before unload warning
window.addEventListener("beforeunload", function (e) {
if (editorState.modified) {
const confirmationMessage = "You have unsaved changes. Are you sure you want to leave?";
e.returnValue = confirmationMessage;
return confirmationMessage;
}
});
// Set up "Use Current View" button
const useCurrentViewBtn = document.getElementById("useCurrentViewBtn");
if (useCurrentViewBtn) {
useCurrentViewBtn.addEventListener("click", useCurrentViewAsDefault);
} else {
console.warn("Use Current View button not found");
}
console.log("Editor initialization complete");
}
// Set the current camera orientation as the default view
function useCurrentViewAsDefault() {
console.log("Setting current view as default");
// Get the camera element
const camera = document.querySelector("a-camera");
if (!camera) {
console.error("Camera element not found");
showNotification("Camera not found", "error");
return;
}
// Get the camera rig (parent of camera)
const rig = camera.parentElement;
if (!rig) {
console.error("Camera rig not found");
showNotification("Camera rig not found", "error");
return;
}
// Debug: Log all available components and properties
console.log("Camera components:", camera.components);
console.log("Rig components:", rig.components);
console.log("Rig attributes:", rig.attributes);
console.log("Camera object3D:", camera.object3D);
console.log("Rig object3D:", rig.object3D);
// Try to get rotation from look-controls component
let x = 0, y = 0, z = 0;
if (camera.components && camera.components['look-controls']) {
const lookControls = camera.components['look-controls'];
console.log("Look controls:", lookControls);
// Look controls typically store pitch (x) and yaw (y)
if (lookControls.pitchObject && lookControls.yawObject) {
x = THREE.MathUtils.radToDeg(lookControls.pitchObject.rotation.x);
y = THREE.MathUtils.radToDeg(lookControls.yawObject.rotation.y);
z = 0; // Z rotation is typically not used in look-controls
console.log("Look controls rotation:", { x, y, z });
}
}
// If we couldn't get values from look-controls, try the rig's rotation attribute
if (x === 0 && y === 0 && z === 0) {
// Get current rotation from the rig element
const rotation = rig.getAttribute("rotation");
if (rotation) {
x = rotation.x;
y = rotation.y;
z = rotation.z;
console.log("Rig rotation attribute:", rotation);
}
}
// If we still don't have values, try the object3D directly
if (x === 0 && y === 0 && z === 0) {
if (rig.object3D) {
// Force the object3D to update its matrix
rig.object3D.updateMatrixWorld(true);
// Get Euler angles from the world matrix
const euler = new THREE.Euler().setFromRotationMatrix(rig.object3D.matrixWorld);
x = THREE.MathUtils.radToDeg(euler.x);
y = THREE.MathUtils.radToDeg(euler.y);
z = THREE.MathUtils.radToDeg(euler.z);
console.log("Object3D world rotation:", { x, y, z });
}
}
// As a last resort, try to get camera position and calculate rotation
if (x === 0 && y === 0 && z === 0) {
// This is a simplified approach - in a real VR scene, you might need more complex calculations
console.log("Using last resort method to calculate rotation");
// Get the current camera direction by raycasting forward
const raycaster = new THREE.Raycaster();
camera.object3D.updateMatrixWorld(true);
raycaster.setFromCamera(new THREE.Vector2(0, 0), camera.object3D.children[0]);
// Calculate rotation based on the ray direction
const direction = raycaster.ray.direction;
y = THREE.MathUtils.radToDeg(Math.atan2(direction.x, direction.z));
x = THREE.MathUtils.radToDeg(Math.atan2(-direction.y, Math.sqrt(direction.x * direction.x + direction.z * direction.z)));
console.log("Calculated rotation from direction:", { x, y, z });
}
// Update the input fields with the current rotation values
const defaultXInput = document.getElementById("defaultX");
const defaultYInput = document.getElementById("defaultY");
const defaultZInput = document.getElementById("defaultZ");
if (defaultXInput) defaultXInput.value = parseFloat(x).toFixed(2);
if (defaultYInput) defaultYInput.value = parseFloat(y).toFixed(2);
if (defaultZInput) defaultZInput.value = parseFloat(z).toFixed(2);
// Mark the scene as modified
editorState.modified = true;
// Show success notification
showNotification("Current view set as default", "success");
// Highlight the input fields briefly to show they've been updated
[defaultXInput, defaultYInput, defaultZInput].forEach(input => {
if (input) {
input.classList.add("highlight-update");
setTimeout(() => {
input.classList.remove("highlight-update");
}, 1000);
}
});
}
// Function to update preview from input values
function updatePreviewFromInputs() {
const previewElement = document.querySelector(".element-preview");
if (!previewElement) return;
const x = parseFloat(document.getElementById("position_x").value) || 0;
const y = parseFloat(document.getElementById("position_y").value) || 0;
const z = parseFloat(document.getElementById("position_z").value) || 0;
console.log("Updating preview position from inputs:", x, y, z);
previewElement.setAttribute("position", `${x} ${y} ${z}`);
}
// Custom position handling for direct input
function setupDirectPositionHandling() {
console.log("Setting up direct position handling");
// Listen for changes to position inputs
document.addEventListener("input", function (event) {
if (event.target.id === "position_x" ||
event.target.id === "position_y" ||
event.target.id === "position_z") {
updatePreviewFromInputs();
}
});
// Override the A-Frame click-drag behavior
document.addEventListener("mouseup", function (event) {
const previewElement = document.querySelector(".element-preview");
if (!previewElement) return;
// Get the current position after drag
const position = previewElement.getAttribute("position");
if (!position) return;
// Update form inputs
const posXInput = document.getElementById("position_x");
const posYInput = document.getElementById("position_y");
const posZInput = document.getElementById("position_z");
if (posXInput) posXInput.value = position.x.toFixed(2);
if (posYInput) posYInput.value = position.y.toFixed(2);
if (posZInput) posZInput.value = position.z.toFixed(2);
console.log("Updated inputs after drag:", position);
});
}
// Set up tool buttons
function setupToolButtons() {
const selectTool = document.getElementById("selectTool");
const markerTool = document.getElementById("markerTool");
const imageTool = document.getElementById("imageTool");
const teleportTool = document.getElementById("teleportTool");
if (selectTool) {
selectTool.addEventListener("click", () => setEditMode("select"));
} else {
console.warn("Select tool button not found");
}
if (markerTool) {
markerTool.addEventListener("click", () => setEditMode("marker"));
} else {
console.warn("Marker tool button not found");
}
if (imageTool) {
imageTool.addEventListener("click", () => setEditMode("image"));
} else {
console.warn("Image tool button not found");
}
if (teleportTool) {
teleportTool.addEventListener("click", () => setEditMode("teleport"));
} else {
console.warn("Teleport tool button not found");
}
}
// Set up Add Element dropdown
function setupAddElementDropdown() {
// Set up Add Element buttons
const addMarkerBtn = document.getElementById("addMarkerBtn");
if (addMarkerBtn) {
addMarkerBtn.addEventListener("click", function () {
setEditMode("marker");
simulateClickOnSky();
});
} else {
console.warn("Add marker button not found");
}
const addImageBtn = document.getElementById("addImageBtn");
if (addImageBtn) {
addImageBtn.addEventListener("click", function () {
setEditMode("image");
simulateClickOnSky();
});
} else {
console.warn("Add image button not found");
}
const addTeleportBtn = document.getElementById("addTeleportBtn");
if (addTeleportBtn) {
addTeleportBtn.addEventListener("click", function () {
setEditMode("teleport");
simulateClickOnSky();
});
} else {
console.warn("Add teleport button not found");
}
}
// Set edit mode
function setEditMode(mode) {
console.log("Setting edit mode to:", mode);
editorState.editMode = mode;
// Update UI
const toolButtons = document.querySelectorAll(".tool-btn");
toolButtons.forEach(btn => btn.classList.remove("active"));
const activeButton = document.getElementById(`${mode}Tool`);
if (activeButton) {
activeButton.classList.add("active");
}
// Update cursor based on mode
const scene = document.querySelector("a-scene");
if (scene) {
scene.style.cursor = mode === "select" ? "default" : "crosshair";
}
// Show mode in coordinate display
const coordinateDisplay = document.getElementById("coordinateDisplay");
if (coordinateDisplay) {
coordinateDisplay.textContent = `Mode: ${mode.charAt(0).toUpperCase() + mode.slice(1)}`;
}
}
// Handle scene loaded event
function handleSceneLoaded(event) {
console.log("Scene loaded in editor");
// Get the scene
const scene = document.querySelector("a-scene");
if (!scene) {
console.error("A-Scene element not found");
return;
}
// Get scene ID from the parent quackscape-scene element
const sceneElement = scene.closest("quackscape-scene");
if (!sceneElement) {
console.error("Parent quackscape-scene element not found");
return;
}
const sceneId = sceneElement.getAttribute("scene");
console.log("Scene ID:", sceneId);
if (!sceneId) {
console.error("Scene ID not found on quackscape-scene element");
return;
}
// Load scene data
getScene(sceneId).then(response => {
console.log("Scene data loaded:", response.obj);
editorState.scene = response.obj;
editorState.originalData = JSON.parse(JSON.stringify(response.obj));
// Update element count
updateElementCount();
// Populate elements list
populateElementsList();
// Fill scene settings form
fillSceneSettingsForm();
// Log scene ID for debugging
console.log("Scene ID stored in editorState:", editorState.scene.id);
}).catch(error => {
console.error("Error loading scene data:", error);
showNotification("Failed to load scene data", "error");
});
// Add event listeners to all elements
setupElementEventListeners(scene);
// Set up scene click handler
setupSceneClickHandler();
// Add click listener to sky for adding new elements
const sky = scene.querySelector("a-sky");
if (sky) {
sky.addEventListener("click", handleSkyClick);
console.log("Added click listener to sky");
} else {
console.error("Sky element not found");
}
}
// Fill scene settings form
function fillSceneSettingsForm() {
if (!editorState.scene) return;
const titleInput = document.getElementById("sceneTitle");
const descriptionInput = document.getElementById("sceneDescription");
const defaultXInput = document.getElementById("defaultX");
const defaultYInput = document.getElementById("defaultY");
const defaultZInput = document.getElementById("defaultZ");
const publicCheckbox = document.getElementById("scenePublic");
if (titleInput) titleInput.value = editorState.scene.title || "";
if (descriptionInput) descriptionInput.value = editorState.scene.description || "";
if (defaultXInput) defaultXInput.value = editorState.scene.default_x || 0;
if (defaultYInput) defaultYInput.value = editorState.scene.default_y || 0;
if (defaultZInput) defaultZInput.value = editorState.scene.default_z || 0;
if (publicCheckbox) publicCheckbox.checked = editorState.scene.public !== false;
}
// Set up element event listeners
function setupElementEventListeners(scene) {
// Get all interactive elements
const elements = scene.querySelectorAll("[id]");
elements.forEach(element => {
if (element.tagName.startsWith("A-") &&
element.tagName !== "A-SCENE" &&
element.tagName !== "A-ASSETS" &&
element.tagName !== "A-SKY" &&
element.tagName !== "A-CAMERA") {
console.log("Setting up event listeners for element:", element.tagName, element.getAttribute("id"));
// Make element clickable
element.setAttribute("data-clickable", "");
// Remove original onclick events
if (element.hasAttribute("onclick")) {
const onclickValue = element.getAttribute("onclick");
element.removeAttribute("onclick");
element.dataset.originalOnclick = onclickValue;
}
// Add event listeners
element.addEventListener("mousedown", handleElementMouseDown);
element.addEventListener("mouseup", handleElementMouseUp);
element.addEventListener("click", handleElementClick);
}
});
}
// Handle mouse down on element
function handleElementMouseDown(event) {
editorState.clickTimestamp = event.timeStamp;
editorState.isDragging = false;
// Make element draggable in select mode
if (editorState.editMode === "select") {
event.target.setAttribute("click-drag", "enabled: true;");
}
}
// Handle mouse up on element
function handleElementMouseUp(event) {
const dragDuration = event.timeStamp - editorState.clickTimestamp;
// If it was a short click, treat as a click, not a drag
if (dragDuration < 200) {
editorState.isDragging = false;
} else {
editorState.isDragging = true;
updateElementLocation(event);
}
}
// Handle click on element
function handleElementClick(event) {
// Ignore if we're dragging
if (editorState.isDragging) return;
console.log("Element clicked:", event.target.tagName, event.target.getAttribute("id"));
// In select mode, edit the element
if (editorState.editMode === "select") {
startModifyElement(event);
}
}
// Simplified click handler for adding new elements
function setupSceneClickHandler() {
console.log("Setting up custom scene click handler");
const scene = document.querySelector("a-scene");
if (!scene) return;
// Add click handler to the scene
scene.addEventListener("click", function (event) {
// Only handle clicks in creation modes
if (editorState.editMode === "select") return;
// Check if we clicked on an element (not the background)
if (event.target.tagName !== "A-SCENE" &&
event.target.tagName !== "A-SKY" &&
event.target.tagName !== "CANVAS") {
console.log("Clicked on element, not background");
return;
}
console.log("Background clicked in creation mode:", editorState.editMode);
// Get the camera and raycaster
const camera = scene.camera;
if (!camera) {
console.warn("Camera not found");
return;
}
// Get mouse position
const canvas = scene.canvas;
const rect = canvas.getBoundingClientRect();
const mouseX = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const mouseY = -((event.clientY - rect.top) / rect.height) * 2 + 1;
console.log("Normalized mouse coordinates:", mouseX, mouseY);
// Create raycaster
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(mouseX, mouseY), camera);
// Calculate intersection with the sky sphere
// Create a sphere geometry for intersection testing
const sphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), 5);
// Calculate ray intersection with sphere
const ray = raycaster.ray;
const intersection = new THREE.Vector3();
ray.intersectSphere(sphere, intersection);
console.log("Ray intersection with sphere:", intersection);
// Create synthetic event with the calculated position
const syntheticEvent = {
detail: {
intersection: {
point: {
x: intersection.x,
y: intersection.y,
z: intersection.z
}
}
}
};
// Start creating element
startCreateElement(syntheticEvent);
});
console.log("Custom scene click handler set up");
}
// Create a preview element
function createElementPreview(elementType, position, properties) {
// Remove any existing preview
const existingPreview = document.querySelector(".element-preview");
if (existingPreview) {
existingPreview.parentNode.removeChild(existingPreview);
}
// Constrain the initial position to a sphere
const radius = (elementType === "MarkerElement") ? 5 : 5;
const constrained = constrainToSphere(position.x, position.y, position.z, radius);
console.log("Initial position:", position, "Constrained:", constrained);
// Create element based on type
let previewElement;
switch (elementType) {
case "MarkerElement":
case "TeleportElement":
previewElement = document.createElement("a-entity");
previewElement.setAttribute("geometry", "primitive: sphere; radius: 0.2");
previewElement.setAttribute("material", "color: #4F46E5; shader: flat");
// Make the marker look at the center (invert the normal direction)
previewElement.setAttribute("look-at", "0 0 0");
// Create a separate text entity that will always face the camera
const textEntity = document.createElement("a-text");
textEntity.setAttribute("value", properties.title || 'New Marker');
textEntity.setAttribute("align", "center");
textEntity.setAttribute("width", 3);
textEntity.setAttribute("color", "white");
textEntity.setAttribute("position", "0 0.3 0");
// Make the text always face the camera
textEntity.setAttribute("billboard", "");
// Changes for TeleportElement
if (elementType === "TeleportElement") {
previewElement.setAttribute("material", "color: #10B981; shader: flat");
textEntity.setAttribute("value", properties.title || 'New Teleport');
}
// Add the text as a child of the marker
previewElement.appendChild(textEntity);
break;
case "ImageElement":
previewElement = document.createElement("a-curvedimage");
previewElement.setAttribute("height", properties.height || 1);
previewElement.setAttribute("radius", radius);
previewElement.setAttribute("theta-length", 30);
// If we have an image preview, use it
const imagePreview = document.getElementById("imagePreview");
if (imagePreview && imagePreview.querySelector("img")) {
const imgSrc = imagePreview.querySelector("img").src;
previewElement.setAttribute("src", imgSrc);
} else {
// Use placeholder color
previewElement.setAttribute("material", elementType === "TeleportElement" ?
"color: #10B981" : "color: #3B82F6");
}
// Calculate theta-start based on position
const thetaStart = Math.atan2(constrained.z, constrained.x) * (180 / Math.PI);
previewElement.setAttribute("theta-start", ((thetaStart + 270) % 360));
break;
default:
console.error("Unknown element type for preview:", elementType);
return;
}
// Set common attributes
previewElement.classList.add("element-preview");
previewElement.setAttribute("data-clickable", "");
previewElement.setAttribute("position", `${constrained.x} ${constrained.y} ${constrained.z}`);
previewElement.setAttribute("click-drag", "");
// Add to scene
const scene = document.querySelector("a-scene");
if (scene) {
scene.appendChild(previewElement);
// Add custom drag handler
previewElement.addEventListener("mousedown", startDrag);
document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", endDrag);
return previewElement;
}
return null;
}
// Function to handle drag end
function handlePreviewDragEnd(event) {
console.log("Preview element dragged", event);
// Get the new position from the element
const element = event.target;
const position = element.getAttribute("position");
if (!position) {
console.warn("No position attribute after drag");
return;
}
console.log("Raw position after drag:", position);
// Constrain the position to a sphere
const elementType = document.getElementById("resourcetype")?.value;
const radius = (elementType === "MarkerElement") ? 5 : 5;
const constrained = constrainToSphere(position.x, position.y, position.z, radius);
console.log("Constrained position:", constrained);
// Update the element position to stay on the sphere
element.setAttribute("position", `${constrained.x} ${constrained.y} ${constrained.z}`);
// Update form values
const posXInput = document.getElementById("position_x");
const posYInput = document.getElementById("position_y");
const posZInput = document.getElementById("position_z");
if (posXInput) posXInput.value = constrained.x.toFixed(2);
if (posYInput) posYInput.value = constrained.y.toFixed(2);
if (posZInput) posZInput.value = constrained.z.toFixed(2);
}
// Start creating a new element
function startCreateElement(event) {
console.log("Starting element creation", event);
const propertiesTitle = document.getElementById("propertiesTitle");
if (propertiesTitle) {
propertiesTitle.textContent = "Create Element";
}
const propertiesContent = document.getElementById("propertiesContent");
if (!propertiesContent) {
console.error("Properties content element not found");
return;
}
// Calculate position from intersection point
let position = { x: 0, y: 1.6, z: -5 }; // Default position
try {
if (event.detail && event.detail.intersection && event.detail.intersection.point) {
position = {
x: parseFloat(event.detail.intersection.point.x) || 0,
y: parseFloat(event.detail.intersection.point.y) || 1.6,
z: parseFloat(event.detail.intersection.point.z) || -5
};
}
} catch (e) {
console.warn("Error extracting position from event:", e);
}
console.log("Using position for new element:", position);
// Create form based on element type
let elementType;
switch (editorState.editMode) {
case "marker":
elementType = "MarkerElement";
break;
case "image":
elementType = "ImageElement";
break;
case "teleport":
elementType = "TeleportElement";
break;
default:
console.error("Unknown edit mode:", editorState.editMode);
return;
}
console.log("Creating element of type:", elementType);
// Create debug info
const debugInfo = `
<div class="hide" id="propertiesDebug">
<b>Creating ${elementType} at:</b><br/>
X: ${position.x.toFixed(2)}<br/>
Y: ${position.y.toFixed(2)}<br/>
Z: ${position.z.toFixed(2)}<br/>
<hr/>
</div>
`;
// Create form
propertiesContent.innerHTML = `
${debugInfo}
<form id="newElementForm">
<input type="hidden" id="resourcetype" value="${elementType}">
<input type="hidden" id="csrfmiddlewaretoken" value="${getCookie("csrftoken")}">
<div id="elementProperties"></div>
</form>
`;
// Create element properties form
createElementPropertiesForm(elementType, position);
// Create preview element with the exact same position
createElementPreview(elementType, position, { title: "" });
// Show buttons
const resetButton = document.getElementById("resetButton");
if (resetButton) {
resetButton.classList.remove("hide");
}
const buttons = document.getElementById("buttons");
if (buttons) {
buttons.classList.remove("hide");
}
// Set up save button
const saveButton = document.getElementById("saveButton");
if (saveButton) {
saveButton.onclick = saveNewElement;
}
// Set up cancel button
const cancelButton = document.getElementById("cancelButton");
if (cancelButton) {
cancelButton.onclick = cancelEdit;
}
// Show properties tab
const propertiesTab = document.getElementById("properties-tab");
if (propertiesTab) {
propertiesTab.click();
}
}
// Position listeners to update the preview element
function addPositionInputListeners() {
const posXInput = document.getElementById("position_x");
const posYInput = document.getElementById("position_y");
const posZInput = document.getElementById("position_z");
const updatePreviewPosition = () => {
const previewElement = document.querySelector(".element-preview");
if (!previewElement) return;
const x = parseFloat(posXInput.value) || 0;
const y = parseFloat(posYInput.value) || 0;
const z = parseFloat(posZInput.value) || 0;
// Update the stored position
editorState.currentPosition = { x, y, z };
// Update the preview element position
previewElement.setAttribute("position", `${x} ${y} ${z}`);
console.log("Updated position from form inputs:", editorState.currentPosition);
};
if (posXInput) posXInput.addEventListener("change", updatePreviewPosition);
if (posYInput) posYInput.addEventListener("change", updatePreviewPosition);
if (posZInput) posZInput.addEventListener("change", updatePreviewPosition);
}
function createElementPropertiesForm(elementType, position = { x: 0, y: 0, z: -5 }) {
const elementProperties = document.getElementById("elementProperties");
if (!elementProperties) {
console.error("Element properties container not found");
return;
}
// Common fields for all element types
let formHTML = `
<div class="form-group">
<label for="title" class="form-label">Title</label>
<input type="text" class="form-control" id="title" placeholder="Title" required>
</div>
<div class="form-group">
<label for="position" class="form-label">Position</label>
<div class="input-group mb-2">
<span class="input-group-text">X</span>
<input type="number" class="form-control" id="position_x" name="position_x" value="${position.x.toFixed(2)}" step="0.1">
</div>
<div class="input-group mb-2">
<span class="input-group-text">Y</span>
<input type="number" class="form-control" id="position_y" name="position_y" value="${position.y.toFixed(2)}" step="0.1">
</div>
<div class="input-group">
<span class="input-group-text">Z</span>
<input type="number" class="form-control" id="position_z" name="position_z" value="${position.z.toFixed(2)}" step="0.1">
</div>
</div>
`;
// Add element-specific fields
if (elementType === "ImageElement") {
formHTML += `
<div class="form-group">
<label for="elementUpload" class="form-label">Image</label>
<input id="elementUpload" type="file" class="form-control" accept="image/*" required>
<div class="form-text">Select an image to display</div>
</div>
<div class="form-group">
<label for="elementSize" class="form-label">Size</label>
<div class="input-group mb-2">
<span class="input-group-text">Width</span>
<input type="number" class="form-control" id="width" name="width" value="2" min="0.1" step="0.1">
</div>
<div class="input-group">
<span class="input-group-text">Height</span>
<input type="number" class="form-control" id="height" name="height" value="1" min="0.1" step="0.1">
</div>
</div>
`;
}
// Add teleport-specific fields
if (elementType === "TeleportElement") {
formHTML += `
<div class="form-group">
<label for="destinationDropdownSearch" class="form-label">Teleport Destination</label>
<div class="dropdown-container" style="position: relative;">
<input class="form-control" autocomplete="off" id="destinationDropdownSearch" type="text" placeholder="Search for a scene..." required>
<input type="hidden" id="destination" name="destination" required>
<div id="destinationDropdownMenu" style="width: 100%;"></div>
</div>
</div>
<div class="form-group">
<label class="form-label">Destination View</label>
<div class="input-group mb-2">
<span class="input-group-text">X</span>
<input type="number" class="form-control" id="destination_x" name="destination_x" value="0" step="1">
</div>
<div class="input-group mb-2">
<span class="input-group-text">Y</span>
<input type="number" class="form-control" id="destination_y" name="destination_y" value="0" step="1">
</div>
<div class="input-group">
<span class="input-group-text">Z</span>
<input type="number" class="form-control" id="destination_z" name="destination_z" value="0" step="1">
</div>
<div class="form-text">Set the initial view when teleporting to the destination</div>
</div>
`;
}
elementProperties.innerHTML = formHTML;
// Add position input listeners
addPositionInputListeners();
// Set up destination dropdown for teleport elements
if (elementType === "TeleportElement" && editorState.scene) {
const categoryId = editorState.scene.category;
getCategory(categoryId).then(category => {
populateDestinationDropdown(null, category);
}).catch(error => {
console.error("Error loading category data for destination dropdown:", error);
showNotification("Error loading scenes for destination selection", "error");
});
}
// Set up image preview for image upload
const elementUpload = document.getElementById("elementUpload");
if (elementUpload) {
elementUpload.addEventListener("change", function () {
if (this.files && this.files[0]) {
const reader = new FileReader();
reader.onload = function (e) {
// Create or update preview
let preview = document.getElementById("imagePreview");
if (!preview) {
preview = document.createElement("div");
preview.id = "imagePreview";
preview.className = "image-preview mt-2";
elementUpload.parentNode.appendChild(preview);
}
preview.innerHTML = `<img src="${e.target.result}" alt="Preview" class="img-fluid">`;
// Update the preview element with the new image
const previewElement = document.querySelector(".element-preview");
if (previewElement && previewElement.tagName === "A-CURVEDIMAGE") {
previewElement.setAttribute("src", e.target.result);
}
};
reader.readAsDataURL(this.files[0]);
}
});
}
}
// Start modifying an existing element
function startModifyElement(event) {
console.log("Starting element modification");
// Make the element draggable for positioning
event.target.setAttribute("click-drag", "enabled: true;");
// Listen for "dragend" event to update the element's position
event.target.addEventListener("dragend", updateElementLocation);
// Set active element
editorState.activeElement = event.target;
// editor.js (continued)
// Update UI
const propertiesTitle = document.getElementById("propertiesTitle");
if (propertiesTitle) {
propertiesTitle.textContent = "Edit Element";
}
// Get element data from API
const scene = findParentScene(event.target);
if (!scene) {
console.error("Parent scene not found");
return;
}
const elementId = event.target.getAttribute("id");
if (!elementId) {
console.error("Element ID not found");
return;
}
getSceneElement(scene.getAttribute("id"), elementId).then(element_data => {
console.log("Element data loaded:", element_data.obj);
const propertiesContent = document.getElementById("propertiesContent");
if (!propertiesContent) {
console.error("Properties content element not found");
return;
}
// Create debug info
const debugHTML = `
<div class="hide" id="propertiesDebug">
<b>Modifying element:</b><br/>
Element Type: ${event.target.tagName}<br/>
Element ID: ${elementId}<br/>
Element data: ${JSON.stringify(element_data.obj)}<br/>
<hr/>
</div>
`;
// Create form
propertiesContent.innerHTML = `
${debugHTML}
<form id="modifyElementForm">
<input type="hidden" id="csrfmiddlewaretoken" value="${getCookie("csrftoken")}">
<input type="hidden" id="id" value="${elementId}">
<div id="elementProperties"></div>
</form>
`;
// Create element properties form
createElementPropertiesForm(element_data.obj.resourcetype);
// Fill form with element data
fillFormWithElementData(element_data.obj);
// Show buttons
document.getElementById("resetButton")?.classList.remove("hide");
document.getElementById("deleteButton")?.classList.remove("hide");
document.getElementById("buttons")?.classList.remove("hide");
// Set up save button
const saveButton = document.getElementById("saveButton");
if (saveButton) {
saveButton.onclick = saveModifiedElement;
}
// Set up delete button
const deleteButton = document.getElementById("deleteButton");
if (deleteButton) {
deleteButton.onclick = deleteElement;
}
// Set up cancel button
const cancelButton = document.getElementById("cancelButton");
if (cancelButton) {
cancelButton.onclick = cancelEdit;
}
// Show properties tab
const propertiesTab = document.getElementById("properties-tab");
if (propertiesTab) {
propertiesTab.click();
}
}).catch(error => {
console.error("Error loading element data:", error);
showNotification("Failed to load element data", "error");
});
}
// Fill form with element data
function fillFormWithElementData(elementData) {
console.log("Filling form with element data:", elementData);
// Fill common fields
const titleInput = document.getElementById("title");
const positionXInput = document.getElementById("position_x");
const positionYInput = document.getElementById("position_y");
const positionZInput = document.getElementById("position_z");
if (titleInput) titleInput.value = elementData.label || "";
if (positionXInput) positionXInput.value = elementData.x || 0;
if (positionYInput) positionYInput.value = elementData.y || 0;
if (positionZInput) positionZInput.value = elementData.z || 5;
// Fill element-specific fields
if (elementData.resourcetype === "ImageElement" || elementData.resourcetype === "TeleportElement") {
const widthInput = document.getElementById("width");
const heightInput = document.getElementById("height");
if (widthInput) widthInput.value = elementData.width || 2;
if (heightInput) heightInput.value = elementData.height || 1;
// Show current image
if (elementData.image) {
const imagePreview = document.createElement("div");
imagePreview.id = "imagePreview";
imagePreview.className = "image-preview mt-2";
imagePreview.innerHTML = `
<div class="current-image-label">Current Image:</div>
<img src="${elementData.image}" alt="Current Image" class="img-fluid">
`;
const elementUpload = document.getElementById("elementUpload");
if (elementUpload) {
elementUpload.parentNode.appendChild(imagePreview);
// Make image upload optional for editing
elementUpload.removeAttribute("required");
}
}
}
// Fill teleport-specific fields
if (elementData.resourcetype === "TeleportElement") {
const destinationInput = document.getElementById("destination");
const destinationXInput = document.getElementById("destination_x");
const destinationYInput = document.getElementById("destination_y");
const destinationZInput = document.getElementById("destination_z");
if (destinationInput) destinationInput.value = elementData.destination || "";
// Set destination view if not default (-1)
if (elementData.destination_x !== -1 && destinationXInput) {
destinationXInput.value = elementData.destination_x;
}
if (elementData.destination_y !== -1 && destinationYInput) {
destinationYInput.value = elementData.destination_y;
}
if (elementData.destination_z !== -1 && destinationZInput) {
destinationZInput.value = elementData.destination_z;
}
// Get scene info to populate destination dropdown
getScene(elementData.scene).then(scene_data => {
getCategory(scene_data.obj.category).then(category_data => {
populateDestinationDropdown(elementData.destination, category_data);
});
});
}
}
function saveNewElement() {
console.log("Saving new element");
const form = document.getElementById("newElementForm");
if (!form) {
console.error("New element form not found");
return;
}
// Validate form
if (!form.checkValidity()) {
form.reportValidity();
return;
}
// Check if we have a valid scene ID
if (!editorState.scene || !editorState.scene.id) {
console.error("No valid scene ID found in editorState", editorState);
showNotification("Error: Scene ID not found", "error");
return;
}
// Get form data
const formData = new FormData(form);
const elementType = document.getElementById("resourcetype")?.value;
if (!elementType) {
console.error("Resource type not found");
showNotification("Error: Resource type not found", "error");
return;
}
formData.append("resourcetype", elementType);
// Get position values from form
const posXEl = document.getElementById("position_x");
const posYEl = document.getElementById("position_y");
const posZEl = document.getElementById("position_z");
const posX = posXEl ? parseFloat(posXEl.value) || 0 : 0;
const posY = posYEl ? parseFloat(posYEl.value) || 0 : 0;
const posZ = posZEl ? parseFloat(posZEl.value) || 0 : 0;
// Add common fields
const titleEl = document.getElementById("title");
formData.append("label", titleEl ? titleEl.value : "Unnamed Element");
formData.append("x", posX);
formData.append("y", posY);
formData.append("z", posZ);
formData.append("scene", editorState.scene.id);
console.log("Saving element at position:", { x: posX, y: posY, z: posZ });
console.log("Scene ID:", editorState.scene.id);
// Debug - log all form data
console.log("Form data before sending:");
for (const pair of formData.entries()) {
console.log(pair[0] + ": " + pair[1]);
}
// Add element-specific fields
if (elementType === "TeleportElement") {
const destinationEl = document.getElementById("destination");
const destXEl = document.getElementById("destination_x");
const destYEl = document.getElementById("destination_y");
const destZEl = document.getElementById("destination_z");
if (destinationEl) {
formData.append("destination", destinationEl.value);
} else {
console.error("Destination field not found");
showNotification("Error: Destination field not found", "error");
return;
}
formData.append("destination_x", destXEl ? destXEl.value : 0);
formData.append("destination_y", destYEl ? destYEl.value : 0);
formData.append("destination_z", destZEl ? destZEl.value : 0);
} else if (elementType === "ImageElement") {
const widthEl = document.getElementById("width");
const heightEl = document.getElementById("height");
formData.append("width", widthEl ? widthEl.value : 2);
formData.append("height", heightEl ? heightEl.value : 1);
// Add image file
const fileInput = document.getElementById("elementUpload");
if (fileInput && fileInput.files.length > 0) {
formData.append("image", fileInput.files[0]);
} else {
console.error("No image file selected");
showNotification("Error: Please select an image file", "error");
return;
}
}
// Show loading state
const saveButton = document.getElementById("saveButton");
if (saveButton) {
saveButton.disabled = true;
saveButton.innerHTML = '<i class="ph-light ph-spinner ph-spin"></i> Saving...';
}
// Log the form data for debugging
console.log("Form data entries:");
for (const pair of formData.entries()) {
console.log(pair[0] + ": " + pair[1]);
}
// Send API request
fetch(`/tours/api/scene/${editorState.scene.id}/elements/`, {
method: "POST",
body: formData,
headers: {
"X-CSRFToken": getCookie("csrftoken")
}
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(JSON.stringify(data));
});
}
return response.json();
})
.then(data => {
// Success
console.log("Element created successfully:", data);
showNotification("Element created successfully", "success");
// Update editor state
editorState.modified = true;
// Reset form
cancelEdit();
// Reload scene to show new element
reloadScene();
})
.catch(error => {
console.error("Error creating element:", error);
let errorMessage = "Failed to create element";
try {
const errorData = JSON.parse(error.message);
if (typeof errorData === 'object') {
errorMessage = Object.entries(errorData)
.map(([key, value]) => `${key}: ${value}`)
.join(', ');
}
} catch (e) {
errorMessage = error.message;
}
showNotification(errorMessage, "error");
})
.finally(() => {
// Reset button state
if (saveButton) {
saveButton.disabled = false;
saveButton.innerHTML = '<i class="ph-light ph-check"></i> Save';
}
});
}
// Save a modified element
function saveModifiedElement() {
console.log("Saving modified element");
if (!editorState.activeElement) {
console.error("No active element to save");
return;
}
const form = document.getElementById("modifyElementForm");
if (!form) {
console.error("Modify element form not found");
return;
}
// Validate form
if (!form.checkValidity()) {
form.reportValidity();
return;
}
// Get form data
const formData = new FormData(form);
const elementId = document.getElementById("id").value;
// Add common fields
formData.append("label", document.getElementById("title").value);
formData.append("x", document.getElementById("position_x").value);
formData.append("y", document.getElementById("position_y").value);
formData.append("z", document.getElementById("position_z").value);
formData.append("scene", editorState.scene.id);
// Add element-specific fields
const elementType = editorState.activeElement.tagName.toLowerCase();
if (elementType === "a-curvedimage") {
const widthInput = document.getElementById("width");
const heightInput = document.getElementById("height");
if (widthInput) formData.append("width", widthInput.value);
if (heightInput) formData.append("height", heightInput.value);
// Add image file if provided
const fileInput = document.getElementById("elementUpload");
if (fileInput && fileInput.files.length > 0) {
formData.append("image", fileInput.files[0]);
}
}
// Add teleport-specific fields
if (elementType === "a-entity" && editorState.activeElement.hasAttribute("onclick")) {
const destinationInput = document.getElementById("destination");
const destinationXInput = document.getElementById("destination_x");
const destinationYInput = document.getElementById("destination_y");
const destinationZInput = document.getElementById("destination_z");
if (destinationInput) formData.append("destination", destinationInput.value);
if (destinationXInput) formData.append("destination_x", destinationXInput.value);
if (destinationYInput) formData.append("destination_y", destinationYInput.value);
if (destinationZInput) formData.append("destination_z", destinationZInput.value);
}
// Show loading state
const saveButton = document.getElementById("saveButton");
if (saveButton) {
saveButton.disabled = true;
saveButton.innerHTML = '<i class="ph-light ph-spinner ph-spin"></i> Saving...';
}
// Send API request
fetch(`/tours/api/scene/${editorState.scene.id}/elements/${elementId}/`, {
method: "PUT",
body: formData,
headers: {
"X-CSRFToken": getCookie("csrftoken")
}
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(JSON.stringify(data));
});
}
return response.json();
})
.then(data => {
// Success
console.log("Element updated successfully:", data);
showNotification("Element updated successfully", "success");
// Update editor state
editorState.modified = true;
// Reset form
cancelEdit();
// Reload scene to show updated element
reloadScene();
})
.catch(error => {
console.error("Error updating element:", error);
let errorMessage = "Failed to update element";
try {
const errorData = JSON.parse(error.message);
if (typeof errorData === 'object') {
errorMessage = Object.entries(errorData)
.map(([key, value]) => `${key}: ${value}`)
.join(', ');
}
} catch (e) {
errorMessage = error.message;
}
showNotification(errorMessage, "error");
})
.finally(() => {
// Reset button state
if (saveButton) {
saveButton.disabled = false;
saveButton.innerHTML = '<i class="ph-light ph-check"></i> Save';
}
});
}
// Delete an element
function deleteElement() {
console.log("Deleting element");
if (!editorState.activeElement) {
console.error("No active element to delete");
return;
}
const elementId = editorState.activeElement.getAttribute("id");
if (!elementId) {
console.error("Element ID not found");
return;
}
// Confirm deletion
if (!confirm("Are you sure you want to delete this element?")) {
return;
}
// Show loading state
const deleteButton = document.getElementById("deleteButton");
if (deleteButton) {
deleteButton.disabled = true;
deleteButton.innerHTML = '<i class="ph-light ph-spinner ph-spin"></i> Deleting...';
}
// Send API request
fetch(`/tours/api/scene/${editorState.scene.id}/elements/${elementId}/`, {
method: "DELETE",
headers: {
"X-CSRFToken": getCookie("csrftoken")
}
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
// Success
console.log("Element deleted successfully");
showNotification("Element deleted successfully", "success");
// Update editor state
editorState.modified = true;
// Reset form
cancelEdit();
// Reload scene to reflect deletion
reloadScene();
})
.catch(error => {
console.error("Error deleting element:", error);
showNotification("Failed to delete element: " + error.message, "error");
})
.finally(() => {
// Reset button state
if (deleteButton) {
deleteButton.disabled = false;
deleteButton.innerHTML = '<i class="ph-light ph-trash"></i> Delete';
}
});
}
// Simulates a click on the sky to start creating an element
function simulateClickOnSky() {
console.log("Simulating click on sky");
// Get camera position and direction
const camera = document.querySelector("a-camera");
if (!camera || !camera.object3D) {
console.warn("Camera not found for simulated click");
// Use default position
const syntheticEvent = {
detail: {
intersection: {
point: { x: 0, y: 0, z: -5 }
}
}
};
startCreateElement(syntheticEvent);
return;
}
// Get camera direction
const direction = new THREE.Vector3(0, 0, -1);
direction.applyQuaternion(camera.object3D.quaternion);
direction.normalize();
// Position at radius 5 in the direction the camera is facing
const position = direction.multiplyScalar(5);
// Create synthetic event
const syntheticEvent = {
detail: {
intersection: {
point: {
x: position.x,
y: position.y,
z: position.z
}
}
}
};
// Call the handler directly
startCreateElement(syntheticEvent);
}
function startDrag(event) {
const element = event.target.closest(".element-preview");
if (!element) return;
dragState.isDragging = true;
dragState.element = element;
dragState.lastMouseX = event.clientX;
dragState.lastMouseY = event.clientY;
// Get the element type to determine radius
const elementType = document.getElementById("resourcetype")?.value;
dragState.radius = (elementType === "MarkerElement") ? 5 : 5;
console.log("Started dragging element", element.tagName);
// Prevent default to avoid text selection
event.preventDefault();
}
function handleDrag(event) {
if (!dragState.isDragging || !dragState.element) return;
// Get the scene and camera
const scene = document.querySelector("a-scene");
if (!scene || !scene.camera) return;
// Calculate mouse movement
const deltaX = event.clientX - dragState.lastMouseX;
const deltaY = event.clientY - dragState.lastMouseY;
dragState.lastMouseX = event.clientX;
dragState.lastMouseY = event.clientY;
// Get current position
const position = dragState.element.getAttribute("position");
if (!position) return;
// Convert to spherical coordinates
const spherical = cartesianToSpherical(position.x, position.y, position.z);
// Update spherical coordinates based on mouse movement
// Scale factors determine how fast the element moves with mouse movement
const thetaScale = 0.01;
const phiScale = 0.01;
// Horizontal movement changes theta (rotation around y-axis)
spherical.theta -= deltaX * thetaScale;
// Vertical movement changes phi (angle from y-axis)
spherical.phi += deltaY * phiScale;
// Constrain phi to avoid flipping at poles
spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi));
// Convert back to cartesian
const newPos = sphericalToCartesian(spherical.radius, spherical.phi, spherical.theta);
// Update element position
dragState.element.setAttribute("position", `${newPos.x} ${newPos.y} ${newPos.z}`);
// If it's a marker, make it face the center
if (dragState.element.hasAttribute("look-at")) {
dragState.element.setAttribute("look-at", "0 0 0");
}
// If it's a curved image, update theta-start
if (dragState.element.tagName === "A-CURVEDIMAGE") {
const thetaStart = Math.atan2(newPos.z, newPos.x) * (180 / Math.PI);
dragState.element.setAttribute("theta-start", ((thetaStart + 270) % 360));
}
// Update form values
const posXInput = document.getElementById("position_x");
const posYInput = document.getElementById("position_y");
const posZInput = document.getElementById("position_z");
if (posXInput) posXInput.value = newPos.x.toFixed(2);
if (posYInput) posYInput.value = newPos.y.toFixed(2);
if (posZInput) posZInput.value = newPos.z.toFixed(2);
}
function endDrag(event) {
if (!dragState.isDragging) return;
console.log("Ended dragging");
// Trigger dragend event on the element
if (dragState.element) {
const element = dragState.element;
const position = element.getAttribute("position");
// Create and dispatch a custom event
const dragEndEvent = new CustomEvent("dragend", {
detail: { position }
});
element.dispatchEvent(dragEndEvent);
}
// Reset drag state
dragState.isDragging = false;
dragState.element = null;
}
// Clean up event listeners when changing modes or canceling
function cleanupDragListeners() {
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", endDrag);
}
// Cancel editing
function cancelEdit() {
console.log("Canceling edit");
// Remove preview element
const previewElement = document.querySelector(".element-preview");
if (previewElement) {
previewElement.parentNode.removeChild(previewElement);
}
// Clean up drag listeners
cleanupDragListeners();
// Reset active element
editorState.activeElement = null;
// Reset form
const propertiesContent = document.getElementById("propertiesContent");
if (propertiesContent) {
propertiesContent.innerHTML = `
<div class="properties-placeholder">
<div class="placeholder-icon">
<i class="ph-light ph-cursor-click"></i>
</div>
<p>Click on the scene to create a new element, or on an existing element to edit it.</p>
</div>
`;
}
// Hide buttons
document.getElementById("resetButton")?.classList.add("hide");
document.getElementById("deleteButton")?.classList.add("hide");
document.getElementById("buttons")?.classList.add("hide");
}
// Update element location after dragging
function updateElementLocation(event) {
if (!event.target || !event.detail) {
console.warn("Invalid event in updateElementLocation");
return;
}
const element = event.target;
const offset = event.detail.offset;
console.log("Updating element location with offset:", offset);
// Get the current position
const currentPosition = element.getAttribute("position") || { x: 0, y: 0, z: 0 };
// Calculate new position
const newPosition = {
x: currentPosition.x + offset.x,
y: currentPosition.y + offset.y,
z: currentPosition.z + offset.z
};
// Update element position
element.setAttribute("position", newPosition);
// Store the new position in editor state
editorState.currentPosition = {
x: newPosition.x,
y: newPosition.y,
z: newPosition.z
};
// If it's a curved image, update theta
if (element.tagName === "A-CURVEDIMAGE") {
const theta = cartesianToTheta(newPosition.x, newPosition.z);
element.setAttribute("theta-start", theta);
}
// Mark scene as modified
editorState.modified = true;
// Update coordinate display
const coordinateDisplay = document.getElementById("coordinateDisplay");
if (coordinateDisplay) {
coordinateDisplay.textContent = `X: ${newPosition.x.toFixed(2)}, Y: ${newPosition.y.toFixed(2)}, Z: ${newPosition.z.toFixed(2)}`;
}
// Update form values if they exist
const posXInput = document.getElementById("position_x");
const posYInput = document.getElementById("position_y");
const posZInput = document.getElementById("position_z");
if (posXInput) posXInput.value = newPosition.x.toFixed(2);
if (posYInput) posYInput.value = newPosition.y.toFixed(2);
if (posZInput) posZInput.value = newPosition.z.toFixed(2);
console.log("Position updated to:", newPosition);
}
// Save scene settings
function saveSceneSettings() {
console.log("Saving scene settings");
if (!editorState.scene) {
console.error("No scene data available");
return;
}
// Get form values
const title = document.getElementById("sceneTitle").value;
const description = document.getElementById("sceneDescription").value;
const defaultX = parseFloat(document.getElementById("defaultX").value) || 0;
const defaultY = parseFloat(document.getElementById("defaultY").value) || 0;
const defaultZ = parseFloat(document.getElementById("defaultZ").value) || 0;
const isPublic = document.getElementById("scenePublic").checked;
// Validate inputs
if (!title.trim()) {
showNotification("Please enter a scene title", "error");
document.getElementById("sceneTitle").focus();
return;
}
// Show loading state
const saveSettingsBtn = document.getElementById("saveSettingsBtn");
if (saveSettingsBtn) {
saveSettingsBtn.disabled = true;
saveSettingsBtn.innerHTML = '<i class="ph-light ph-spinner ph-spin"></i> Saving...';
}
// Create form data
const formData = new FormData();
formData.append("title", title);
formData.append("description", description);
formData.append("default_x", defaultX);
formData.append("default_y", defaultY);
formData.append("default_z", defaultZ);
formData.append("public", isPublic);
formData.append("base_content", editorState.scene.base_content.id);
formData.append("category", editorState.scene.category);
// Send API request
fetch(`/tours/api/scenes/${editorState.scene.id}/`, {
method: "PUT",
body: formData,
headers: {
"X-CSRFToken": getCookie("csrftoken")
}
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(JSON.stringify(data));
});
}
return response.json();
})
.then(data => {
// Success
console.log("Scene settings saved successfully:", data);
showNotification("Scene settings saved successfully", "success");
// Update editor state
editorState.scene = data;
editorState.modified = false;
})
.catch(error => {
console.error("Error saving scene settings:", error);
let errorMessage = "Failed to save scene settings";
try {
const errorData = JSON.parse(error.message);
if (typeof errorData === 'object') {
errorMessage = Object.entries(errorData)
.map(([key, value]) => `${key}: ${value}`)
.join(', ');
}
} catch (e) {
errorMessage = error.message;
}
showNotification(errorMessage, "error");
})
.finally(() => {
// Reset button state
if (saveSettingsBtn) {
saveSettingsBtn.disabled = false;
saveSettingsBtn.innerHTML = '<i class="ph-light ph-check"></i> Save Settings';
}
});
}
// Coordinate conversion functions
function cartesianToTheta(x, z) {
// Calculate the angle in radians
let angleRadians = Math.atan2(z, x);
// Convert to degrees
let angleDegrees = angleRadians * (180 / Math.PI);
// A-Frame's thetaStart is measured from the positive X-axis and goes counter-clockwise
let thetaStart = 90 - angleDegrees;
// Normalize to 0 - 360
thetaStart = thetaStart < 0 ? thetaStart + 360 : thetaStart;
return thetaStart;
}
function thetaToCartesian(thetaStart, radius = 1) {
// Convert thetaStart back to standard Cartesian coordinates
let angleDegrees = 90 - thetaStart;
// Normalize to 0 - 360
angleDegrees = angleDegrees % 360;
if (angleDegrees < 0) {
angleDegrees += 360;
}
// Convert to radians
let angleRadians = angleDegrees * (Math.PI / 180);
// Calculate Cartesian coordinates
let x = radius * Math.cos(angleRadians);
let z = radius * Math.sin(angleRadians);
return { x, z };
}
function cartesianToSpherical(x, y, z) {
// Convert Cartesian (x,y,z) to Spherical (radius, phi, theta)
const radius = Math.sqrt(x * x + y * y + z * z);
// Handle the case where radius is zero or very small
if (radius < 0.001) {
return { radius: 0, phi: 0, theta: 0 };
}
// Phi is the angle from the y-axis (0 to PI)
const phi = Math.acos(y / radius);
// Theta is the angle in the xz plane from the positive x-axis (-PI to PI)
const theta = Math.atan2(z, x);
return { radius, phi, theta };
}
function sphericalToCartesian(radius, phi, theta) {
// Convert Spherical (radius, phi, theta) to Cartesian (x,y,z)
// phi: angle from y-axis (0 to PI)
// theta: angle in xz plane from positive x-axis (-PI to PI)
const x = radius * Math.sin(phi) * Math.cos(theta);
const y = radius * Math.cos(phi);
const z = radius * Math.sin(phi) * Math.sin(theta);
return { x, y, z };
}
// Constrain a point to a sphere with given radius
function constrainToSphere(x, y, z, radius = 5) {
const { phi, theta } = cartesianToSpherical(x, y, z);
return sphericalToCartesian(radius, phi, theta);
}
// Find parent quackscape-scene element
function findParentScene(element) {
let parent = element.parentElement;
while (parent && parent.tagName !== "QUACKSCAPE-SCENE") {
parent = parent.parentElement;
}
return parent;
}
// Reload the current scene
function reloadScene() {
console.log("Reloading scene");
const sceneElement = document.querySelector("quackscape-scene");
if (!sceneElement) {
console.error("Quackscape scene element not found");
return;
}
const sceneId = sceneElement.getAttribute("id");
const x = parseInt(sceneElement.getAttribute("x")) || 0;
const y = parseInt(sceneElement.getAttribute("y")) || 0;
const z = parseInt(sceneElement.getAttribute("z")) || 0;
// Show loading indicator
sceneElement.showLoadingIndicator?.();
// Reload scene
window.loadScene(sceneId, x, y, z, sceneElement, sceneElement.hasAttribute("embedded"))
.then(() => {
console.log("Scene reloaded successfully");
// Update element count
updateElementCount();
// Populate elements list
populateElementsList();
// Hide loading indicator
sceneElement.hideLoadingIndicator?.();
// Refresh scene data
getScene(sceneId).then(response => {
editorState.scene = response.obj;
});
})
.catch(error => {
console.error("Error reloading scene:", error);
sceneElement.showErrorIndicator?.(error);
});
}
// Update element count display
function updateElementCount() {
const elementCount = document.getElementById("elementCount");
if (elementCount && editorState.scene) {
const count = editorState.scene.elements.length;
elementCount.textContent = `${count} element${count !== 1 ? 's' : ''}`;
}
}
// Populate elements list in sidebar
function populateElementsList() {
const elementsList = document.querySelector(".elements-list");
if (!elementsList || !editorState.scene) {
console.warn("Elements list or scene data not available");
return;
}
// Clear existing elements
elementsList.innerHTML = "";
if (!editorState.scene.elements || editorState.scene.elements.length === 0) {
elementsList.innerHTML = `
<div class="empty-elements">
<p>No elements added yet</p>
<p>Click on the scene to add elements</p>
</div>
`;
return;
}
// Add each element to the list
editorState.scene.elements.forEach(element => {
const elementItem = document.createElement("div");
elementItem.className = "element-item";
elementItem.dataset.elementId = element.id;
// Determine element type icon
let iconClass = "ph-light ph-cube";
if (element.data.tag === "a-curvedimage") {
if (element.data.attributes.onclick) {
iconClass = "ph-light ph-portal";
} else {
iconClass = "ph-light ph-image-square";
}
} else if (element.data.tag === "a-entity") {
iconClass = "ph-light ph-map-pin";
}
elementItem.innerHTML = `
<div class="element-icon">
<i class="${iconClass}"></i>
</div>
<div class="element-info">
<div class="element-title">${element.data.attributes.alt || "Unnamed Element"}</div>
<div class="element-type">${getElementTypeName(element.data.tag, element.data.attributes)}</div>
</div>
<div class="element-actions">
<button class="btn btn-icon btn-sm element-edit-btn" title="Edit">
<i class="ph-light ph-pencil-simple"></i>
</button>
<button class="btn btn-icon btn-sm element-delete-btn" title="Delete">
<i class="ph-light ph-trash"></i>
</button>
</div>
`;
// Add click handlers
const editBtn = elementItem.querySelector(".element-edit-btn");
if (editBtn) {
editBtn.addEventListener("click", () => {
const elementInScene = document.getElementById(element.id);
if (elementInScene) {
// Simulate click on element
elementInScene.dispatchEvent(new MouseEvent("click"));
} else {
console.warn("Element not found in scene:", element.id);
}
});
}
const deleteBtn = elementItem.querySelector(".element-delete-btn");
if (deleteBtn) {
deleteBtn.addEventListener("click", () => {
const elementInScene = document.getElementById(element.id);
if (elementInScene) {
// Set as active element and delete
editorState.activeElement = elementInScene;
deleteElement();
} else {
console.warn("Element not found in scene for deletion:", element.id);
}
});
}
elementsList.appendChild(elementItem);
});
}
// Get friendly element type name
function getElementTypeName(tag, attributes) {
if (tag === "a-curvedimage") {
return attributes.onclick ? "Teleport" : "Image";
} else if (tag === "a-entity") {
return "Marker";
}
return tag.replace("a-", "");
}
// Set up tab listeners
function setupTabListeners() {
const tabs = document.querySelectorAll('[data-bs-toggle="tab"]');
tabs.forEach(tab => {
tab.addEventListener("click", function () {
const target = document.querySelector(this.dataset.bsTarget);
if (!target) {
console.warn("Tab target not found:", this.dataset.bsTarget);
return;
}
// Hide all tab panes
document.querySelectorAll(".tab-pane").forEach(pane => {
pane.classList.remove("show", "active");
});
// Show selected tab pane
target.classList.add("show", "active");
// Update active tab
tabs.forEach(t => t.classList.remove("active"));
this.classList.add("active");
});
});
}
// Save all changes to the scene
function saveChanges() {
console.log("Saving all changes");
if (!editorState.scene) {
console.error("No scene data available");
return;
}
// Get scene settings
const title = document.getElementById("sceneTitle")?.value || editorState.scene.title;
const description = document.getElementById("sceneDescription")?.value || editorState.scene.description;
const defaultX = parseFloat(document.getElementById("defaultX")?.value) || editorState.scene.default_x || 0;
const defaultY = parseFloat(document.getElementById("defaultY")?.value) || editorState.scene.default_y || 0;
const defaultZ = parseFloat(document.getElementById("defaultZ")?.value) || editorState.scene.default_z || 0;
const isPublic = document.getElementById("scenePublic")?.checked !== undefined ?
document.getElementById("scenePublic").checked : editorState.scene.public;
// Create form data
const formData = new FormData();
formData.append("title", title);
formData.append("description", description);
formData.append("default_x", defaultX);
formData.append("default_y", defaultY);
formData.append("default_z", defaultZ);
formData.append("public", isPublic);
formData.append("base_content", editorState.scene.base_content.id);
formData.append("category", editorState.scene.category);
// Show loading state
const saveBtn = document.getElementById("saveBtn");
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.innerHTML = '<i class="ph-light ph-spinner ph-spin"></i> Saving...';
}
// Send API request
fetch(`/tours/api/scenes/${editorState.scene.id}/`, {
method: "PUT",
body: formData,
headers: {
"X-CSRFToken": getCookie("csrftoken")
}
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(JSON.stringify(data));
});
}
return response.json();
})
.then(data => {
// Success
console.log("Scene saved successfully:", data);
showNotification("Scene saved successfully", "success");
// Update editor state
editorState.scene = data;
editorState.modified = false;
// Store original data for future comparisons
editorState.originalData = JSON.parse(JSON.stringify(data));
})
.catch(error => {
console.error("Error saving scene:", error);
let errorMessage = "Failed to save scene";
try {
const errorData = JSON.parse(error.message);
if (typeof errorData === 'object') {
errorMessage = Object.entries(errorData)
.map(([key, value]) => `${key}: ${value}`)
.join(', ');
}
} catch (e) {
errorMessage = error.message;
}
showNotification(errorMessage, "error");
})
.finally(() => {
// Reset button state
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="ph-light ph-floppy-disk"></i> Save';
}
});
}
// Preview the scene
function previewScene() {
console.log("Previewing scene");
if (!editorState.scene) {
console.error("No scene data available");
return;
}
// Check if there are unsaved changes
if (editorState.modified) {
const confirmPreview = confirm("You have unsaved changes. Save before previewing?");
if (confirmPreview) {
// Save changes first, then preview
saveChanges();
setTimeout(() => {
window.open(`/tours/scene/${editorState.scene.id}/`, "_blank");
}, 1000);
return;
}
}
// Open preview in new tab
window.open(`/tours/scene/${editorState.scene.id}/`, "_blank");
}
// Reset view to default
function resetView() {
console.log("Resetting view");
if (!editorState.scene) {
console.error("No scene data available");
return;
}
const camera = document.querySelector("a-camera");
if (!camera) {
console.error("Camera element not found");
return;
}
const rig = camera.parentElement;
if (!rig) {
console.error("Camera rig not found");
return;
}
// Reset camera rotation
const defaultX = editorState.scene.default_x || 0;
const defaultY = editorState.scene.default_y || 0;
const defaultZ = editorState.scene.default_z || 0;
rig.setAttribute("rotation", {
x: defaultX,
y: defaultY,
z: defaultZ
});
showNotification("View reset to default", "info");
}
// Toggle fullscreen
function toggleFullscreen() {
console.log("Toggling fullscreen");
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => {
console.error("Error attempting to enable fullscreen:", err);
showNotification(`Error enabling fullscreen: ${err.message}`, "error");
});
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
}
// Debug function to toggle debug visibility
function toggleDebugVisibility() {
const debugElement = document.getElementById("propertiesDebug");
if (debugElement) {
debugElement.classList.toggle("hide");
}
}
// Add keyboard shortcut for debug mode
document.addEventListener("keydown", function (event) {
if (event.ctrlKey && event.shiftKey && event.key === "D") {
toggleDebugVisibility();
event.preventDefault();
}
});
// Export necessary functions
export { initEditor };