Refactor MuJoCo Live to separate out HTML, CSS, and JS components.
PiperOrigin-RevId: 915340338 Change-Id: Ic62b9d640af9967a59a77fa59a3556b916d5a24c
This commit is contained in:
committed by
Copybara-Service
parent
c56d662d27
commit
e47809d50d
@@ -14,6 +14,8 @@ name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "doc/**"
|
||||
- "**/README.md"
|
||||
|
||||
@@ -317,7 +317,7 @@ build_mujoco_live() {
|
||||
-DMUJOCO_USE_FILAMENT=ON \
|
||||
-DMUJOCO_BUILD_TESTS_WASM=OFF \
|
||||
-DMUJOCO_NATIVE_BUILD_DIR=$(pwd)/build_host
|
||||
cmake --build build_wasm --target mujoco_live -j$(nproc)
|
||||
cmake --build build_wasm --target mujoco_studio -j$(nproc)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ jobs:
|
||||
run: |
|
||||
mkdir -p dist/bin
|
||||
cp -r build_wasm/bin/* dist/bin/
|
||||
cp src/experimental/studio/index.html dist/index.html
|
||||
cp src/experimental/studio/live.html dist/index.html
|
||||
cp src/experimental/studio/live.css dist/live.css
|
||||
cp src/experimental/studio/live.js dist/live.js
|
||||
COMMIT_HASH=$(git rev-parse HEAD 2>/dev/null || true)
|
||||
if [ -n "$COMMIT_HASH" ]; then
|
||||
sed -i "s/__COMMIT_HASH_PLACEHOLDER__/$COMMIT_HASH/g" dist/index.html
|
||||
|
||||
@@ -98,19 +98,19 @@ endif()
|
||||
|
||||
# WASM Live target.
|
||||
if(EMSCRIPTEN)
|
||||
add_executable(mujoco_live)
|
||||
target_sources(mujoco_live PRIVATE wasm.cc)
|
||||
target_compile_options(mujoco_live PRIVATE -g)
|
||||
configure_studio_target(mujoco_live)
|
||||
target_link_libraries(mujoco_live PRIVATE mujoco::render_noop)
|
||||
add_executable(mujoco_studio)
|
||||
target_sources(mujoco_studio PRIVATE emscripten.cc)
|
||||
target_compile_options(mujoco_studio PRIVATE -g)
|
||||
configure_studio_target(mujoco_studio)
|
||||
target_link_libraries(mujoco_studio PRIVATE mujoco::render_noop)
|
||||
|
||||
# Ensure resource decoder plugins are linked into the WASM binary.
|
||||
target_link_options(mujoco_live PRIVATE
|
||||
target_link_options(mujoco_studio PRIVATE
|
||||
-Wl,--whole-archive $<TARGET_FILE:mujoco> -Wl,--no-whole-archive
|
||||
)
|
||||
|
||||
# Filament's OpenGL backend requires WebGL2 / full ES3 bindings.
|
||||
target_link_options(mujoco_live PRIVATE
|
||||
target_link_options(mujoco_studio PRIVATE
|
||||
--bind
|
||||
-sUSE_WEBGL2=1
|
||||
-sFULL_ES3
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>MuJoCo Live</title>
|
||||
</head>
|
||||
<body style="margin: 0; overflow: hidden">
|
||||
<div id="commitHash" style="
|
||||
position: absolute; top: 3px; right: 3px; z-index: 1;
|
||||
font-family: monospace; font-size: 9px; color: #fff;
|
||||
mix-blend-mode: difference;
|
||||
padding: 2px 4px; user-select: all;" title="Build commit">__COMMIT_HASH_PLACEHOLDER__</div>
|
||||
<script>
|
||||
(function () {
|
||||
var el = document.getElementById('commitHash');
|
||||
if (el && el.textContent === '__COMMIT_HASH_PLACEHOLDER__') el.remove();
|
||||
})();
|
||||
</script>
|
||||
<canvas
|
||||
class="emscripten"
|
||||
id="canvas"
|
||||
oncontextmenu="event.preventDefault()"
|
||||
style="width: 100vw; height: 100vh; display: block"
|
||||
></canvas>
|
||||
<script>
|
||||
// --- Loading overlay (shown while model is compiling) ---
|
||||
var loadingOverlay = document.createElement('div');
|
||||
loadingOverlay.id = 'loadingOverlay';
|
||||
loadingOverlay.style.cssText =
|
||||
'position:fixed;top:0;left:0;width:100%;height:100%;' +
|
||||
'background:rgba(0,0,0,0.6);z-index:10000;display:none;' +
|
||||
'align-items:center;justify-content:center;';
|
||||
loadingOverlay.innerHTML =
|
||||
'<div style="color:#fff;font-size:24px;font-family:sans-serif;' +
|
||||
'text-align:center;">' +
|
||||
'<div style="margin-bottom:16px;">Loading model\u2026</div>' +
|
||||
'<div style="font-size:14px;opacity:0.7;">This may take a moment ' +
|
||||
'for large models.</div></div>';
|
||||
document.body.appendChild(loadingOverlay);
|
||||
|
||||
function showLoading() {
|
||||
loadingOverlay.style.display = 'flex';
|
||||
}
|
||||
function hideLoading() {
|
||||
loadingOverlay.style.display = 'none';
|
||||
}
|
||||
|
||||
var Module = {
|
||||
preRun: [],
|
||||
postRun: [],
|
||||
locateFile: function(path) {
|
||||
const baseURL = window.location.origin + window.location.pathname.substring(0, window.location.pathname.lastIndexOf("/"));
|
||||
return baseURL + "/bin/" + path;
|
||||
},
|
||||
print: console.log,
|
||||
printErr: text => {
|
||||
console.error(text + "\n" + new Error().stack);
|
||||
},
|
||||
canvas: (() => {
|
||||
const canvas = document.getElementById("canvas");
|
||||
// As a default initial behavior, pop up an alert when webgl context is lost. To make your
|
||||
// application robust, you may want to override this behavior before shipping!
|
||||
// See http://www.khronos.org/registry/webgl/specs/latest/1.0/#5.15.2
|
||||
canvas.addEventListener(
|
||||
"webglcontextlost",
|
||||
e => {
|
||||
alert("WebGL context lost. You will need to reload the page.");
|
||||
e.preventDefault();
|
||||
},
|
||||
false,
|
||||
);
|
||||
return canvas;
|
||||
})(),
|
||||
setStatus(text) {},
|
||||
totalDependencies: 0,
|
||||
monitorRunDependencies(left) {},
|
||||
onRuntimeInitialized: () => {
|
||||
// Define assets to prefetch. These paths are relative to the mujoco_live_wasm/ directory.
|
||||
const assetsToPrefetch = [
|
||||
"assets/fontawesome-webfont.ttf",
|
||||
"assets/ibl.ktx",
|
||||
"assets/OpenSans-Regular.ttf",
|
||||
"assets/pbr.filamat",
|
||||
"assets/pbr_packed.filamat",
|
||||
"assets/phong_2d_fade.filamat",
|
||||
"assets/phong_2d.filamat",
|
||||
"assets/phong_2d_reflect.filamat",
|
||||
"assets/phong_2d_uv_fade.filamat",
|
||||
"assets/phong_2d_uv.filamat",
|
||||
"assets/phong_2d_uv_reflect.filamat",
|
||||
"assets/phong_color_fade.filamat",
|
||||
"assets/phong_color.filamat",
|
||||
"assets/phong_color_reflect.filamat",
|
||||
"assets/phong_cube_fade.filamat",
|
||||
"assets/phong_cube.filamat",
|
||||
"assets/phong_cube_reflect.filamat",
|
||||
"assets/unlit_decor.filamat",
|
||||
"assets/unlit_depth.filamat",
|
||||
"assets/unlit_segmentation.filamat",
|
||||
"assets/unlit_ui.filamat"
|
||||
];
|
||||
|
||||
const assetPromises = assetsToPrefetch.map(async (relativePath) => {
|
||||
const assetUrl = Module.locateFile(relativePath);
|
||||
try {
|
||||
const response = await fetch(assetUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${assetUrl}: ${response.statusText}`);
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
const filename = relativePath.substring(relativePath.lastIndexOf('/') + 1);
|
||||
Module.registerAsset(filename, new Uint8Array(buffer));
|
||||
console.log(`Registered asset: ${filename}`);
|
||||
} catch (error) {
|
||||
console.error(`Error prefetching asset ${assetUrl}:`, error);
|
||||
throw error; // Re-throw to be caught by Promise.all
|
||||
}
|
||||
});
|
||||
|
||||
Promise.all(assetPromises)
|
||||
.then(() => {
|
||||
try {
|
||||
Module.init();
|
||||
|
||||
// Check for a ?model= URL parameter and load from URL.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const modelUrl = params.get('model');
|
||||
if (modelUrl) {
|
||||
// loadUrl uses ASYNCIFY (via EM_ASYNC_JS fetch), which
|
||||
// suspends the WASM module. We must not start the animation
|
||||
// loop until the load completes, otherwise renderFrame will
|
||||
// hit "Cannot have multiple async operations in flight".
|
||||
showLoading();
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(async () => {
|
||||
try {
|
||||
await Module.loadUrl(modelUrl);
|
||||
} catch (error) {
|
||||
console.error('Failed to load model from URL:', error);
|
||||
} finally {
|
||||
hideLoading();
|
||||
requestAnimationFrame(Module.animate);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
requestAnimationFrame(Module.animate);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize app.', error);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to prefetch one or more assets.', error);
|
||||
});
|
||||
},
|
||||
animate: async () => {
|
||||
try {
|
||||
await Module.renderFrame();
|
||||
} catch (error) {
|
||||
console.error('Update error:', error);
|
||||
}
|
||||
requestAnimationFrame(Module.animate);
|
||||
},
|
||||
};
|
||||
// Ensure the canvas is resized when the window is resized.
|
||||
window.addEventListener("resize", function () {
|
||||
Module.canvas.style.width = window.innerWidth + "px";
|
||||
Module.canvas.style.height = window.innerHeight + "px";
|
||||
});
|
||||
|
||||
function handleFile(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const buffer = e.target.result;
|
||||
// Show the loading overlay, then defer the blocking WASM call by two
|
||||
// animation frames so the browser has a chance to paint the overlay.
|
||||
showLoading();
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
Module.loadFile(file.name, buffer);
|
||||
} catch (error) {
|
||||
console.error('Failed to load model from file:', error);
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
reader.onerror = (e) => {
|
||||
console.error('Error reading file:', e);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- Drag-and-drop support ---
|
||||
const dropOverlay = document.createElement('div');
|
||||
dropOverlay.id = 'dropOverlay';
|
||||
dropOverlay.style.cssText =
|
||||
'position:fixed;top:0;left:0;width:100%;height:100%;' +
|
||||
'background:rgba(0,0,0,0.5);z-index:9999;display:none;' +
|
||||
'pointer-events:none;align-items:center;justify-content:center;';
|
||||
dropOverlay.innerHTML =
|
||||
'<div style="color:#fff;font-size:24px;font-family:sans-serif;' +
|
||||
'padding:32px 48px;border:3px dashed #fff;border-radius:16px;">' +
|
||||
'Drop model file here</div>';
|
||||
document.body.appendChild(dropOverlay);
|
||||
|
||||
let dragCounter = 0;
|
||||
|
||||
document.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter++;
|
||||
if (dragCounter === 1) {
|
||||
dropOverlay.style.display = 'flex';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter--;
|
||||
if (dragCounter === 0) {
|
||||
dropOverlay.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter = 0;
|
||||
dropOverlay.style.display = 'none';
|
||||
const files = e.dataTransfer.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
handleFile(files[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script async src="bin/mujoco_live.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
#loadingOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 10000;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.loading-container {
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
.loading-title {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.loading-subtitle {
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
#dropOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.drop-message {
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-family: sans-serif;
|
||||
padding: 32px 48px;
|
||||
border: 3px dashed #fff;
|
||||
border-radius: 16px;
|
||||
}
|
||||
#commitHash {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
z-index: 1;
|
||||
font-family: monospace;
|
||||
font-size: 7pt;
|
||||
color: #fff;
|
||||
mix-blend-mode: difference;
|
||||
user-select: all;
|
||||
}
|
||||
#canvas {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="live.css">
|
||||
<title>MuJoCo Live</title>
|
||||
</head>
|
||||
<body style="margin: 0; overflow: hidden">
|
||||
<div id="loadingOverlay">
|
||||
<div class="loading-container">
|
||||
<div class="loading-title">Loading model…</div>
|
||||
<div class="loading-subtitle">This may take a moment for large models.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dropOverlay">
|
||||
<div class="drop-message">Drop model file here</div>
|
||||
</div>
|
||||
<div id="commitHash">__COMMIT_HASH_PLACEHOLDER__</div>
|
||||
<canvas id="canvas" class="emscripten" oncontextmenu="event.preventDefault()"></canvas>
|
||||
<script src="live.js"></script>
|
||||
<script async src="bin/mujoco_studio.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,212 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// --- Loading overlay (shown while model is compiling) ---
|
||||
const loadingOverlay = document.getElementById('loadingOverlay');
|
||||
function showLoading() {
|
||||
loadingOverlay.style.display = 'flex';
|
||||
}
|
||||
function hideLoading() {
|
||||
loadingOverlay.style.display = 'none';
|
||||
}
|
||||
|
||||
var Module = {
|
||||
preRun: [],
|
||||
postRun: [],
|
||||
locateFile: function (path) {
|
||||
const baseURL = window.location.origin + window.location.pathname.substring(0, window.location.pathname.lastIndexOf("/"));
|
||||
return baseURL + "/bin/" + path;
|
||||
},
|
||||
print: console.log,
|
||||
printErr: text => {
|
||||
console.error(text + "\n" + new Error().stack);
|
||||
},
|
||||
canvas: (() => {
|
||||
const canvas = document.getElementById("canvas");
|
||||
// As a default initial behavior, pop up an alert when webgl context is lost. To make your
|
||||
// application robust, you may want to override this behavior before shipping!
|
||||
// See http://www.khronos.org/registry/webgl/specs/latest/1.0/#5.15.2
|
||||
canvas.addEventListener(
|
||||
"webglcontextlost",
|
||||
e => {
|
||||
alert("WebGL context lost. You will need to reload the page.");
|
||||
e.preventDefault();
|
||||
},
|
||||
false,
|
||||
);
|
||||
return canvas;
|
||||
})(),
|
||||
setStatus(text) { },
|
||||
totalDependencies: 0,
|
||||
monitorRunDependencies(left) { },
|
||||
onRuntimeInitialized: () => {
|
||||
// Define assets to prefetch, relative to the WASM directory.
|
||||
const assetsToPrefetch = [
|
||||
"assets/fontawesome-webfont.ttf",
|
||||
"assets/ibl.ktx",
|
||||
"assets/OpenSans-Regular.ttf",
|
||||
"assets/pbr.filamat",
|
||||
"assets/pbr_packed.filamat",
|
||||
"assets/phong_2d_fade.filamat",
|
||||
"assets/phong_2d.filamat",
|
||||
"assets/phong_2d_reflect.filamat",
|
||||
"assets/phong_2d_uv_fade.filamat",
|
||||
"assets/phong_2d_uv.filamat",
|
||||
"assets/phong_2d_uv_reflect.filamat",
|
||||
"assets/phong_color_fade.filamat",
|
||||
"assets/phong_color.filamat",
|
||||
"assets/phong_color_reflect.filamat",
|
||||
"assets/phong_cube_fade.filamat",
|
||||
"assets/phong_cube.filamat",
|
||||
"assets/phong_cube_reflect.filamat",
|
||||
"assets/unlit_decor.filamat",
|
||||
"assets/unlit_depth.filamat",
|
||||
"assets/unlit_segmentation.filamat",
|
||||
"assets/unlit_ui.filamat"
|
||||
];
|
||||
|
||||
const assetPromises = assetsToPrefetch.map(async (relativePath) => {
|
||||
const assetUrl = Module.locateFile(relativePath);
|
||||
try {
|
||||
const response = await fetch(assetUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${assetUrl}: ${response.statusText}`);
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
const filename = relativePath.substring(relativePath.lastIndexOf('/') + 1);
|
||||
Module.registerAsset(filename, new Uint8Array(buffer));
|
||||
console.log(`Registered asset: ${filename}`);
|
||||
} catch (error) {
|
||||
console.error(`Error prefetching asset ${assetUrl}:`, error);
|
||||
throw error; // Re-throw to be caught by Promise.all
|
||||
}
|
||||
});
|
||||
|
||||
Promise.all(assetPromises)
|
||||
.then(() => {
|
||||
try {
|
||||
Module.init();
|
||||
|
||||
// Check for a ?model= URL parameter and load from URL.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const modelUrl = params.get('model');
|
||||
if (modelUrl) {
|
||||
// loadUrl uses ASYNCIFY (via EM_ASYNC_JS fetch), which
|
||||
// suspends the WASM module. We must not start the animation
|
||||
// loop until the load completes, otherwise renderFrame will
|
||||
// hit "Cannot have multiple async operations in flight".
|
||||
showLoading();
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(async () => {
|
||||
try {
|
||||
await Module.loadUrl(modelUrl);
|
||||
} catch (error) {
|
||||
console.error('Failed to load model from URL:', error);
|
||||
} finally {
|
||||
hideLoading();
|
||||
requestAnimationFrame(Module.animate);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
requestAnimationFrame(Module.animate);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize app.', error);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to prefetch one or more assets.', error);
|
||||
});
|
||||
},
|
||||
animate: async () => {
|
||||
try {
|
||||
await Module.renderFrame();
|
||||
} catch (error) {
|
||||
console.error('Update error:', error);
|
||||
}
|
||||
requestAnimationFrame(Module.animate);
|
||||
},
|
||||
};
|
||||
// Ensure the canvas is resized when the window is resized.
|
||||
window.addEventListener("resize", function () {
|
||||
Module.canvas.style.width = window.innerWidth + "px";
|
||||
Module.canvas.style.height = window.innerHeight + "px";
|
||||
});
|
||||
|
||||
function handleFile(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const buffer = e.target.result;
|
||||
// Show the loading overlay, then defer the blocking WASM call by two
|
||||
// animation frames so the browser has a chance to paint the overlay.
|
||||
showLoading();
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
Module.loadFile(file.name, buffer);
|
||||
} catch (error) {
|
||||
console.error('Failed to load model from file:', error);
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
reader.onerror = (e) => {
|
||||
console.error('Error reading file:', e);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Remove commit hash if not substituted
|
||||
var el = document.getElementById('commitHash');
|
||||
if (el && el.textContent === '__COMMIT_HASH_PLACEHOLDER__') el.remove();
|
||||
|
||||
// --- Drag-and-drop support ---
|
||||
const dropOverlay = document.getElementById('dropOverlay');
|
||||
|
||||
let dragCounter = 0;
|
||||
|
||||
document.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter++;
|
||||
if (dragCounter === 1) {
|
||||
dropOverlay.style.display = 'flex';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter--;
|
||||
if (dragCounter === 0) {
|
||||
dropOverlay.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter = 0;
|
||||
dropOverlay.style.display = 'none';
|
||||
const files = e.dataTransfer.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
handleFile(files[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user