Compare commits

..
9 Commits
Author SHA1 Message Date
myxelium 9e442eaa48 bump version 2026-01-08 05:59:26 +01:00
myxelium 1a1e36eabd New visual style and multifloor support 2026-01-08 05:57:56 +01:00
myxelium 61fa58fcc3 Mulitple larger fixes 2026-01-08 04:35:14 +01:00
myxeliumandGitHub e36ecd9ab7 Update README.md 2025-08-18 12:51:45 +02:00
myxeliumandGitHub e86ff3f1de Update module.json 2025-08-18 00:10:46 +02:00
myxeliumandGitHub f02760144b Update README.md 2025-08-17 23:52:18 +02:00
myxeliumandGitHub 5a5ad415ab Update README.md 2025-08-17 23:21:38 +02:00
myxeliumandGitHub 6523b0c0b5 First version
Import images, videos as backgrounds, all in a drag and drop action.

Scene data import, example from Dungeon Alchemist with wall, doors and light information.

If only image is imported, the module can attempt to align the grid automatically.

This version may contain bugs and issues.
2025-08-17 23:03:27 +02:00
myxeliumandGitHub 3c3f538579 Initial commit 2025-08-17 22:58:59 +02:00
21 changed files with 5917 additions and 382 deletions
+66 -44
View File
@@ -1,59 +1,81 @@
# Easy Battlemap for Foundry VTT ![GitHub Downloads (specific asset, all releases)](https://img.shields.io/github/downloads/Myxelium/FoundryVTT-Quick-Import/myxeliums-battlemap-importer.zip)
This module allows you to quickly create battlemaps in Foundry VTT by simply dragging and dropping a background image and a JSON file containing wall data.
## Myxelium's Battlemap Importer
Effortlessly turn single images or exported map data into readytoplay Foundry VTT scenes. Drop an image or video for the background, optionally drop a JSON export with walls and lights, and create a complete scene in seconds.
### What it is
Myxeliums Battlemap Importer is a Foundry VTT module that adds a simple "Quick import" button to the Scenes sidebar. It opens a window where you can drag and drop a background image or video and, if you have it, a JSON configuration file. The module uploads the media, applies grid settings, creates walls, lights and doors etc, and builds a new scene for you automatically.
### Why it exists
Setting up scenes manually can be slow: uploading backgrounds, measuring grid size, placing walls, and configuring lights. This module removes repetitive steps so you can spend more time playing and less time navigating configuration windows.
### Inside foundry
<img width="629" height="497" alt="image" src="./images/importer.png" />
<img width="538" height="422" alt="image" src="./images/image1.png" />
### The problem it solves
- Converts a plain map image into a playable scene with minimal effort
- It attempts to apply grid settings automatically from submitted image
- Imports walls and lights from common JSON exports (ex, Dungeon Alchemist)
- Saves time for game masters managing many scenes
## Key features
- Drag-and-drop panel for images, videos, and JSON configuration files
- Automatic grid detection for images when no JSON is provided
- Imports walls and ambient lights from supported JSON
- Creates and activates a new scene with the uploaded background
- Optional "No grid" toggle for gridless maps
- GM-only quick access button in the Scenes directory
## Compatibility
- Foundry VTT compatibility: minimum 12, verified 13
## Installation ## Installation
1. In the Foundry VTT setup screen, go to "Add-on Modules" tab 1. Open Foundry VTT and go to the "Add-on Modules" tab
2. Click "Install Module" 2. Click "Install Module"
3. Paste the following URL in the "Manifest URL" field: 3. Paste this Manifest URL:
`https://github.com/MyxeliumI/easy-battlemap/releases/latest/download/module.json` https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/module.json
4. Click "Install" 4. Click "Install"
Manual download (optional):
- Download ZIP: https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/myxeliums-battlemap-importer.zip
## Usage ## Usage
1. Enable the module in your game world 1. Enable the module in your world
2. Navigate to the "Scenes" tab 2. Open the Scenes sidebar
3. You'll see a new "Easy Battlemap Creator" panel 3. Click the "Quick import" button (GM only)
4. Drag and drop your background image (jpg, png) 4. In the panel, drag and drop one of the following:
5. Drag and drop your JSON file with wall data - Background image (png, jpg, jpeg) or video (webm, mp4)
6. Once both files are loaded, click "Create Battlemap Scene" - Optional JSON export with walls and lights (for example, from tools like Dungeon Alchemist or compatible Foundry exports)
5. Optionally enable "No grid" if the map is gridless
6. Click "Create Scene"
## JSON Format The module uploads the media to your world, applies grid settings (auto-detected if no JSON was supplied), and creates walls and lights when present.
The JSON file should contain wall data in the following format: ## Supported inputs
```json - Media: PNG, JPG/JPEG, WEBM, MP4
{ - JSON configuration: walls and ambient lights in common Foundry-compatible formats; many Dungeon Alchemist exports should work out of the box
"walls": [
{
"c": [x1, y1, x2, y2],
"door": 0,
"move": 0,
"sense": 0,
"dir": 0,
"ds": 0,
"flags": {}
},
// ... more walls
],
"lights": [
// optional light data
],
"notes": [
// optional note data
],
"tokens": [
// optional token data
],
"drawings": [
// optional drawing data
]
}
```
For the specific format details, refer to the [Foundry VTT REST API Relay documentation](https://github.com/ThreeHats/foundryvtt-rest-api-relay/wiki/create-POST#request-payload). ## Notes and limitations
## License - Grid auto-detection runs for images when no JSON is provided and may not succeed on all artwork
- Auto-detection is intentionally skipped for videos
- You can always create a scene with only a background; adjust grid later if needed
This module is licensed under the MIT License. ## Credits
- Author: Myxelium (https://github.com/Myxelium)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

+43 -8
View File
@@ -1,16 +1,51 @@
{ {
"EASYBATTLEMAP": { "QUICKBATTLEMAP": {
"Ready": "Easy Battlemap module is ready", "Ready": "Myxeliums Battlemap Importer is ready",
"DropAreaTitle": "Easy Battlemap Creator", "DropAreaTitle": "Myxeliums Battlemap Importer",
"DropInstructions": "Drop a background image and a JSON file with wall data here", "DropInstructions": "Drop background images/videos and optionally JSON files with wall/light data here to create battlemap scenes.",
"BackgroundStatus": "Background Image", "DropInstructionsMore": "Multiple files can be dropped to create multi-floor scenes (requires Levels module). Files with matching names will be paired automatically.",
"BackgroundStatus": "Background Media",
"WallDataStatus": "Wall Data", "WallDataStatus": "Wall Data",
"CreateScene": "Create Battlemap Scene", "CreateScene": "Create Battlemap Scene",
"MissingFiles": "Both background image and wall data JSON are required", "MissingFiles": "A background image or video is required",
"CreatingScene": "Creating battlemap scene...", "CreatingScene": "Creating battlemap scene...",
"CreatingMultiFloorScene": "Creating multi-floor battlemap scene...",
"SceneCreated": "Battlemap scene created", "SceneCreated": "Battlemap scene created",
"UploadFailed": "Failed to upload background image", "UploadFailed": "Failed to upload background media",
"SceneCreationFailed": "Failed to create scene", "SceneCreationFailed": "Failed to create scene",
"InvalidJSON": "The JSON file could not be parsed" "InvalidJSON": "The JSON file could not be parsed",
"DefaultSceneName": "New Battlemap",
"ControlTitle": "Myxeliums Battlemap Importer",
"Options": "Options",
"NoGridLabel": "Create scene with no grid",
"NoGridHint": "Skips applying or detecting a grid. Useful for maps without visible grid lines.",
"ProgressLabel": "Progress",
"ProgressIdle": "Waiting for files...",
"ProgressAnalyzing": "Analyzing image to auto-detect grid...",
"ProgressUploading": "Uploading background media...",
"ProgressNote": "Shows ongoing tasks like uploading and auto-detecting grid size.",
"Reset": "Reset",
"FloorsTitle": "Floors",
"FloorsCount": "floor(s)",
"FloorsEmpty": "Drop files to add floors",
"FloorLevel": "Floor",
"NoMedia": "No media",
"NoJson": "No wall data",
"MoveUp": "Move up",
"MoveDown": "Move down",
"RemoveFloor": "Remove floor",
"UnmatchedFiles": "Unmatched files need assignment",
"UnmatchedMedia": "Media files",
"UnmatchedJson": "JSON files",
"AssignToFloor": "Assign to floor",
"NoFloorsToMatch": "Add floors first before assigning JSON files",
"SelectFloorFor": "Select a floor for:",
"SelectFloor": "-- Select a floor --",
"CreateNewFloor": "Create new floor",
"MatchFileTitle": "Assign File to Floor",
"Confirm": "Confirm",
"Cancel": "Cancel",
"GMOnly": "GM only",
"LevelsModuleRequired": "Multi-floor scenes require the Levels module by theripper93"
} }
} }
+10 -10
View File
@@ -1,11 +1,11 @@
{ {
"id": "easy-battlemap", "id": "quick-battlemap-importer",
"title": "Easy Battlemap", "title": "Myxeliums Battlemap Importer",
"description": "Create battlemaps by simply dragging in a background image and wall data JSON file", "description": "Import battlemaps by simply dragging in a background image and wall/light data JSON file",
"version": "1.0.0", "version": "1.5.11",
"compatibility": { "compatibility": {
"minimum": "10", "minimum": "10",
"verified": "11" "verified": "13"
}, },
"authors": [ "authors": [
{ {
@@ -13,8 +13,8 @@
"url": "https://github.com/Myxelium" "url": "https://github.com/Myxelium"
} }
], ],
"esmodules": ["scripts/easy-battlemap.js"], "esmodules": ["scripts/myxeliums-battlemap.js"],
"styles": ["styles/easy-battlemap.css"], "styles": ["styles/myxeliums-battlemap.css"],
"languages": [ "languages": [
{ {
"lang": "en", "lang": "en",
@@ -22,7 +22,7 @@
"path": "languages/en.json" "path": "languages/en.json"
} }
], ],
"url": "https://github.com/Myxelium/test-module", "url": "https://github.com/Myxelium/FoundryVTT-Quick-Import",
"manifest": "https://github.com/Myxelium/test-module/releases/latest/download/module.json", "manifest": "https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/module.json",
"download": "https://github.com/Myxelium/test-module/releases/latest/download/easy-battlemap.zip" "download": "https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/myxeliums-battlemap-importer.zip"
} }
Executable
+97
View File
@@ -0,0 +1,97 @@
#!/bin/bash
# Release script for Myxeliums Battlemap Importer
# Increments version in module.json and creates a release zip
set -e
MODULE_FILE="module.json"
ZIP_NAME="myxeliums-battlemap-importer.zip"
# Check if module.json exists
if [ ! -f "$MODULE_FILE" ]; then
echo "Error: $MODULE_FILE not found!"
exit 1
fi
# Get current version
CURRENT_VERSION=$(grep -oP '"version":\s*"\K[0-9]+\.[0-9]+\.[0-9]+' "$MODULE_FILE")
if [ -z "$CURRENT_VERSION" ]; then
echo "Error: Could not find version in $MODULE_FILE"
exit 1
fi
echo "Current version: $CURRENT_VERSION"
# Parse version components
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
# Increment patch version by default
# Use arguments to control which part to increment:
# -M or --major: increment major version
# -m or --minor: increment minor version
# -p or --patch: increment patch version (default)
INCREMENT_TYPE="patch"
while [[ $# -gt 0 ]]; do
case $1 in
-M|--major)
INCREMENT_TYPE="major"
shift
;;
-m|--minor)
INCREMENT_TYPE="minor"
shift
;;
-p|--patch)
INCREMENT_TYPE="patch"
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [-M|--major] [-m|--minor] [-p|--patch]"
exit 1
;;
esac
done
case $INCREMENT_TYPE in
major)
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
;;
minor)
MINOR=$((MINOR + 1))
PATCH=0
;;
patch)
PATCH=$((PATCH + 1))
;;
esac
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
echo "New version: $NEW_VERSION"
# Update version in module.json
sed -i "s/\"version\":\s*\"$CURRENT_VERSION\"/\"version\": \"$NEW_VERSION\"/" "$MODULE_FILE"
# Also update manifest and download URLs if they contain the version
sed -i "s|/releases/download/$CURRENT_VERSION/|/releases/download/$NEW_VERSION/|g" "$MODULE_FILE"
echo "Updated $MODULE_FILE with version $NEW_VERSION"
# Remove old zip if it exists
if [ -f "$ZIP_NAME" ]; then
rm "$ZIP_NAME"
echo "Removed old $ZIP_NAME"
fi
# Create zip of all visible files (excluding hidden files/folders and the zip itself)
zip -r "$ZIP_NAME" . -x ".*" -x "*/.*" -x "$ZIP_NAME" -x "release.sh"
echo "Created $ZIP_NAME"
echo ""
echo "Release $NEW_VERSION complete!"
-14
View File
@@ -1,14 +0,0 @@
import { EasyBattlemapDropHandler } from './lib/drop-handler.js';
Hooks.once('init', async function() {
console.log('Easy Battlemap | Initializing Easy Battlemap');
});
Hooks.once('ready', async function() {
console.log('Easy Battlemap | Ready');
// Register the drop handler when Foundry is ready
new EasyBattlemapDropHandler().registerDropHandler();
ui.notifications.info(game.i18n.localize("EASYBATTLEMAP.Ready"));
});
-253
View File
@@ -1,253 +0,0 @@
export class EasyBattlemapDropHandler {
constructor() {
this.backgroundImage = null;
this.wallData = null;
this.processingQueue = {};
}
registerDropHandler() {
// Add the drop area to the sidebar
this._addDropArea();
// Register the drop handler on the canvas
this._registerCanvasDropHandler();
// Register the drop handler for the custom drop area
this._registerDropAreaHandler();
}
_addDropArea() {
const dropAreaHTML = `
<div id="easy-battlemap-drop-area" class="app">
<header class="app-header flexrow">
<h4>${game.i18n.localize("EASYBATTLEMAP.DropAreaTitle")}</h4>
</header>
<section class="easy-battlemap-dropzone">
<div class="easy-battlemap-instructions">
${game.i18n.localize("EASYBATTLEMAP.DropInstructions")}
</div>
<div class="easy-battlemap-status">
<div class="background-status">
${game.i18n.localize("EASYBATTLEMAP.BackgroundStatus")}: <span class="status-value">❌</span>
</div>
<div class="wall-data-status">
${game.i18n.localize("EASYBATTLEMAP.WallDataStatus")}: <span class="status-value">❌</span>
</div>
</div>
<button class="create-scene-button" disabled>
${game.i18n.localize("EASYBATTLEMAP.CreateScene")}
</button>
</section>
</div>
`;
// Add to sidebar
const sidebarTab = document.getElementById('scenes');
if (sidebarTab) {
sidebarTab.insertAdjacentHTML('afterend', dropAreaHTML);
// Add click handler for the create button
document.querySelector('.create-scene-button').addEventListener('click', () => {
this._createScene();
});
}
}
_registerCanvasDropHandler() {
// Handle drops on the canvas
canvas.stage.on('drop', (event) => {
this._handleDrop(event.data.originalEvent);
});
}
_registerDropAreaHandler() {
const dropArea = document.getElementById('easy-battlemap-drop-area');
if (!dropArea) return;
// Add event listeners for drag and drop
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
}, false);
});
// Handle drag enter/over
['dragenter', 'dragover'].forEach(eventName => {
dropArea.addEventListener(eventName, () => {
dropArea.classList.add('highlight');
}, false);
});
// Handle drag leave/drop
['dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, () => {
dropArea.classList.remove('highlight');
}, false);
});
// Handle drop
dropArea.addEventListener('drop', (e) => {
this._handleDrop(e);
}, false);
}
_handleDrop(event) {
const files = event.dataTransfer.files;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.match('image.*')) {
this._processImageFile(file);
} else if (file.name.endsWith('.json')) {
this._processJSONFile(file);
}
}
}
_processImageFile(file) {
const reader = new FileReader();
reader.onload = (e) => {
this.backgroundImage = {
data: e.target.result,
filename: file.name
};
// Update the UI to show the background is ready
document.querySelector('.background-status .status-value').textContent = '✅';
document.querySelector('.background-status .status-value').title = file.name;
this._checkReadyToCreate();
};
reader.readAsDataURL(file);
}
_processJSONFile(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const jsonData = JSON.parse(e.target.result);
this.wallData = jsonData;
// Update the UI to show the wall data is ready
document.querySelector('.wall-data-status .status-value').textContent = '✅';
document.querySelector('.wall-data-status .status-value').title = file.name;
this._checkReadyToCreate();
} catch (error) {
ui.notifications.error(game.i18n.localize("EASYBATTLEMAP.InvalidJSON"));
}
};
reader.readAsText(file);
}
_checkReadyToCreate() {
const createButton = document.querySelector('.create-scene-button');
if (this.backgroundImage && this.wallData) {
createButton.disabled = false;
} else {
createButton.disabled = true;
}
}
async _createScene() {
if (!this.backgroundImage || !this.wallData) {
ui.notifications.error(game.i18n.localize("EASYBATTLEMAP.MissingFiles"));
return;
}
try {
ui.notifications.info(game.i18n.localize("EASYBATTLEMAP.CreatingScene"));
// First, we need to upload the background image to the server
const uploadResponse = await this._uploadImage(this.backgroundImage);
if (!uploadResponse.path) {
ui.notifications.error(game.i18n.localize("EASYBATTLEMAP.UploadFailed"));
return;
}
// Extract scene name from the image filename
const sceneName = this.backgroundImage.filename.split('.').slice(0, -1).join('.');
// Get image dimensions
const img = new Image();
img.src = this.backgroundImage.data;
await new Promise(resolve => {
img.onload = resolve;
});
// Create the scene
const sceneData = {
name: sceneName,
img: uploadResponse.path,
width: img.width,
height: img.height,
padding: 0,
backgroundColor: "#000000",
grid: {
type: 1, // Square grid
size: 100,
color: "#000000",
alpha: 0.2
},
walls: this.wallData.walls || [],
lights: this.wallData.lights || [],
notes: this.wallData.notes || [],
tokens: this.wallData.tokens || [],
drawings: this.wallData.drawings || []
};
const scene = await Scene.create(sceneData);
// Reset the data after creating the scene
this.backgroundImage = null;
this.wallData = null;
// Reset the UI
document.querySelector('.background-status .status-value').textContent = '❌';
document.querySelector('.wall-data-status .status-value').textContent = '❌';
document.querySelector('.create-scene-button').disabled = true;
ui.notifications.success(`${game.i18n.localize("EASYBATTLEMAP.SceneCreated")}: ${sceneName}`);
// Activate the scene
await scene.activate();
} catch (error) {
console.error(error);
ui.notifications.error(game.i18n.localize("EASYBATTLEMAP.SceneCreationFailed"));
}
}
async _uploadImage(imageData) {
const formData = new FormData();
// Convert base64 to a blob
const base64Response = await fetch(imageData.data);
const blob = await base64Response.blob();
formData.append('file', blob, imageData.filename);
formData.append('path', 'scenes');
const response = await fetch('/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${game.data.session}`
},
body: formData
});
if (!response.ok) {
throw new Error('Upload failed');
}
return await response.json();
}
}
+285
View File
@@ -0,0 +1,285 @@
/**
* File Processor
*
* Handles processing of dropped files (images, videos, and JSON configs).
* Extracts file reading and type detection logic for cleaner separation of concerns.
*
* @module FileProcessor
*/
/** Module identifier for console logging */
const MODULE_LOG_PREFIX = 'Myxeliums Battlemap Importer';
/**
* @typedef {Object} ProcessedImageData
* @property {string} dataUrl - Base64 data URL of the image
* @property {string} filename - Original filename
* @property {File} file - The original File object
* @property {boolean} isVideo - Always false for images
*/
/**
* @typedef {Object} ProcessedVideoData
* @property {string} blobUrl - Blob URL for the video
* @property {string} filename - Original filename
* @property {File} file - The original File object
* @property {boolean} isVideo - Always true for videos
*/
/**
* @typedef {Object} ProcessedJsonData
* @property {Object} parsedContent - The parsed JSON object
* @property {string} filename - Original filename
*/
/** Supported image file extensions */
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'];
/** Supported video file extensions */
const VIDEO_EXTENSIONS = ['.webm', '.mp4', '.mov', '.m4v', '.avi', '.mkv', '.ogv', '.ogg'];
/**
* Service class for processing dropped files.
* Handles reading files and determining their types.
*/
export class FileProcessor {
/**
* Process a dropped image file and return its data.
* Reads the file as a base64 data URL.
*
* @param {File} imageFile - The image file to process
* @returns {Promise<ProcessedImageData>} Processed image data
*
* @example
* const processor = new FileProcessor();
* const imageData = await processor.processImageFile(droppedFile);
* // imageData.dataUrl contains the base64 image
*/
processImageFile(imageFile) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (loadEvent) => {
resolve({
dataUrl: loadEvent.target.result,
filename: imageFile.name,
file: imageFile,
isVideo: false
});
};
fileReader.onerror = (error) => {
reject(new Error(`Failed to read image file: ${error.message}`));
};
fileReader.readAsDataURL(imageFile);
});
}
/**
* Process a dropped video file and return its data.
* Creates a blob URL for the video.
*
* @param {File} videoFile - The video file to process
* @returns {ProcessedVideoData} Processed video data
*
* @example
* const processor = new FileProcessor();
* const videoData = processor.processVideoFile(droppedFile);
* // videoData.blobUrl can be used as video src
*/
processVideoFile(videoFile) {
const blobUrl = URL.createObjectURL(videoFile);
return {
blobUrl: blobUrl,
filename: videoFile.name,
file: videoFile,
isVideo: true
};
}
/**
* Process a dropped JSON configuration file.
* Reads and parses the JSON content.
*
* @param {File} jsonFile - The JSON file to process
* @returns {Promise<ProcessedJsonData>} Processed JSON data
* @throws {Error} If the JSON is invalid
*
* @example
* const processor = new FileProcessor();
* const config = await processor.processJsonFile(droppedFile);
* // config.parsedContent contains the parsed object
*/
processJsonFile(jsonFile) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (loadEvent) => {
try {
const parsedContent = JSON.parse(loadEvent.target.result);
resolve({
parsedContent: parsedContent,
filename: jsonFile.name
});
} catch (parseError) {
reject(new Error(`Invalid JSON: ${parseError.message}`));
}
};
fileReader.onerror = (error) => {
reject(new Error(`Failed to read JSON file: ${error.message}`));
};
fileReader.readAsText(jsonFile);
});
}
/**
* Determine the type of a file based on MIME type and extension.
*
* @param {File} file - The file to classify
* @returns {'image' | 'video' | 'json' | 'unknown'} The file type category
*
* @example
* const fileType = processor.getFileType(droppedFile);
* if (fileType === 'image') { ... }
*/
getFileType(file) {
const lowercaseFilename = file.name.toLowerCase();
const mimeType = file.type.toLowerCase();
// Check by MIME type first
if (mimeType.startsWith('image/')) {
return 'image';
}
if (mimeType.startsWith('video/')) {
return 'video';
}
if (mimeType === 'application/json') {
return 'json';
}
// Fall back to extension checking
if (this.hasExtension(lowercaseFilename, IMAGE_EXTENSIONS)) {
return 'image';
}
if (this.hasExtension(lowercaseFilename, VIDEO_EXTENSIONS)) {
return 'video';
}
if (lowercaseFilename.endsWith('.json')) {
return 'json';
}
return 'unknown';
}
/**
* Check if a filename has one of the specified extensions.
*
* @param {string} filename - The filename to check (lowercase)
* @param {string[]} extensions - Array of extensions to match
* @returns {boolean} True if filename ends with one of the extensions
*/
hasExtension(filename, extensions) {
return extensions.some(ext => filename.endsWith(ext));
}
/**
* Revoke a blob URL to free memory.
* Safe to call with non-blob URLs (will be ignored).
*
* @param {string} url - The URL to potentially revoke
*/
revokeBlobUrl(url) {
try {
if (url && typeof url === 'string' && url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
} catch (_error) {
// Ignore errors during cleanup
}
}
/**
* Get the dimensions of an image from a data URL.
*
* @param {string} imageDataUrl - Base64 data URL of the image
* @returns {Promise<{width: number, height: number}>} Image dimensions
*/
async getImageDimensions(imageDataUrl) {
return new Promise((resolve, reject) => {
const imageElement = new Image();
imageElement.onload = () => {
resolve({
width: imageElement.width,
height: imageElement.height
});
};
imageElement.onerror = () => {
reject(new Error('Failed to load image for dimension measurement'));
};
imageElement.src = imageDataUrl;
});
}
/**
* Get the dimensions of a video from a URL.
*
* @param {string} videoUrl - URL or blob URL of the video
* @returns {Promise<{width: number|undefined, height: number|undefined}>} Video dimensions
*/
async getVideoDimensions(videoUrl) {
return new Promise((resolve) => {
const videoElement = document.createElement('video');
videoElement.preload = 'metadata';
const cleanup = () => {
videoElement.onloadedmetadata = null;
videoElement.onerror = null;
};
videoElement.onloadedmetadata = () => {
cleanup();
resolve({
width: videoElement.videoWidth || undefined,
height: videoElement.videoHeight || undefined
});
};
videoElement.onerror = () => {
cleanup();
resolve({ width: undefined, height: undefined });
};
videoElement.src = videoUrl;
});
}
/**
* Get dimensions of media (either image or video).
*
* @param {Object} mediaData - Media data object with data/blobUrl and isVideo flag
* @returns {Promise<{width: number|undefined, height: number|undefined}>} Media dimensions
*/
async getMediaDimensions(mediaData) {
try {
if (mediaData?.isVideo) {
return await this.getVideoDimensions(mediaData.data || mediaData.blobUrl);
}
return await this.getImageDimensions(mediaData?.data || mediaData?.dataUrl);
} catch (error) {
console.warn(`${MODULE_LOG_PREFIX} | Could not read media dimensions:`, error);
return { width: undefined, height: undefined };
}
}
}
+300
View File
@@ -0,0 +1,300 @@
/**
* Grid Detection Service
*
* Provides automatic grid detection for battlemap images using signal processing.
* Analyzes edge patterns in images to detect periodic grid lines and calculate
* grid size and offset values.
*
* The algorithm works by:
* 1. Scaling the image for processing efficiency
* 2. Converting to grayscale and detecting edges using Sobel operators
* 3. Projecting edges onto X and Y axes
* 4. Applying high-pass filter to emphasize periodic patterns
* 5. Using autocorrelation to find the dominant period (grid size)
* 6. Estimating offset to align grid with detected lines
*
* @module GridDetectionService
*/
import {
computeAutocorrelation,
applyHighPassFilter,
normalizeSignal,
findBestPeriodFromAutocorrelation,
combinePeriodCandidates,
estimateGridOffset,
clampValue
} from './signal-processing-utils.js';
/** Maximum dimension for image processing (larger images are scaled down) */
const MAX_PROCESSING_DIMENSION = 1600;
/** Minimum valid grid period to filter out noise */
const MIN_VALID_PERIOD = 6;
/**
* @typedef {Object} GridDetectionResult
* @property {number} gridSize - Detected grid cell size in pixels (in original image coordinates)
* @property {number} xOffset - Horizontal offset for grid alignment
* @property {number} yOffset - Vertical offset for grid alignment
*/
/**
* Service class for detecting grid patterns in battlemap images.
* Uses signal processing techniques to find periodic grid lines.
*/
export class GridDetectionService {
/**
* Detect grid settings from an image file.
* Analyzes the image for periodic patterns that indicate grid lines.
*
* @param {File} imageFile - The image file to analyze
* @param {Array<{x: number, y: number}>} [manualPoints] - Optional manual grid points for fallback
* @returns {Promise<GridDetectionResult>} Detected grid settings
* @throws {Error} If grid detection fails
*
* @example
* const detector = new GridDetectionService();
* try {
* const result = await detector.detectGridFromImage(imageFile);
* console.log(`Grid size: ${result.gridSize}px`);
* } catch (error) {
* console.log('Could not detect grid automatically');
* }
*/
async detectGridFromImage(imageFile, manualPoints = null) {
const imageElement = await this.loadImageFromFile(imageFile);
const { scaledCanvas, scaleFactor } = this.createScaledCanvas(imageElement);
const grayscaleData = this.extractGrayscaleData(scaledCanvas);
const edgeMagnitude = this.computeSobelMagnitude(grayscaleData, scaledCanvas.width, scaledCanvas.height);
const { projectionX, projectionY } = this.computeEdgeProjections(edgeMagnitude, scaledCanvas.width, scaledCanvas.height);
const filteredX = this.processProjection(projectionX, scaledCanvas.width);
const filteredY = this.processProjection(projectionY, scaledCanvas.height);
const detectedPeriod = this.detectPeriodFromProjections(filteredX, filteredY, scaledCanvas.width, scaledCanvas.height);
if (detectedPeriod && Number.isFinite(detectedPeriod) && detectedPeriod >= MIN_VALID_PERIOD) {
return this.buildDetectionResult(detectedPeriod, filteredX, filteredY, scaleFactor);
}
if (manualPoints && manualPoints.length >= 2) {
return this.detectFromManualPoints(manualPoints);
}
throw new Error('Grid detection failed; insufficient periodic signal.');
}
/**
* Load an image from a File object into an HTMLImageElement.
*
* @param {File} file - The image file to load
* @returns {Promise<HTMLImageElement>} The loaded image element
*/
loadImageFromFile(file) {
return new Promise((resolve, reject) => {
const imageElement = new Image();
const objectUrl = URL.createObjectURL(file);
imageElement.onload = () => {
URL.revokeObjectURL(objectUrl);
resolve(imageElement);
};
imageElement.onerror = (error) => {
URL.revokeObjectURL(objectUrl);
reject(error);
};
imageElement.src = objectUrl;
});
}
/**
* Create a scaled canvas for processing. Large images are scaled down for performance.
*
* @param {HTMLImageElement} image - The source image
* @returns {{scaledCanvas: HTMLCanvasElement, scaleFactor: number}} Canvas and scale info
*/
createScaledCanvas(image) {
const scaleFactor = Math.min(1, MAX_PROCESSING_DIMENSION / Math.max(image.width, image.height));
const scaledWidth = Math.max(1, Math.round(image.width * scaleFactor));
const scaledHeight = Math.max(1, Math.round(image.height * scaleFactor));
const canvas = document.createElement('canvas');
canvas.width = scaledWidth;
canvas.height = scaledHeight;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0, scaledWidth, scaledHeight);
return { scaledCanvas: canvas, scaleFactor };
}
/**
* Extract grayscale pixel data from a canvas using luminance formula.
*
* @param {HTMLCanvasElement} canvas - The source canvas
* @returns {Float32Array} Grayscale values (0-255)
*/
extractGrayscaleData(canvas) {
const context = canvas.getContext('2d', { willReadFrequently: true });
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const rgbaPixels = imageData.data;
const pixelCount = canvas.width * canvas.height;
const grayscale = new Float32Array(pixelCount);
for (let pixelIndex = 0, rgbaIndex = 0; pixelIndex < pixelCount; pixelIndex++, rgbaIndex += 4) {
const red = rgbaPixels[rgbaIndex];
const green = rgbaPixels[rgbaIndex + 1];
const blue = rgbaPixels[rgbaIndex + 2];
grayscale[pixelIndex] = 0.299 * red + 0.587 * green + 0.114 * blue;
}
return grayscale;
}
/**
* Compute edge magnitude using Sobel operators for gradient detection.
*
* @param {Float32Array} grayscale - Grayscale pixel data
* @param {number} width - Image width
* @param {number} height - Image height
* @returns {Float32Array} Edge magnitude for each pixel
*/
computeSobelMagnitude(grayscale, width, height) {
const output = new Float32Array(width * height);
const sobelX = [-1, 0, 1, -2, 0, 2, -1, 0, 1];
const sobelY = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let gradientX = 0, gradientY = 0, kernelIndex = 0;
for (let kernelY = -1; kernelY <= 1; kernelY++) {
const sampleY = clampValue(y + kernelY, 0, height - 1);
for (let kernelX = -1; kernelX <= 1; kernelX++) {
const sampleX = clampValue(x + kernelX, 0, width - 1);
const pixelValue = grayscale[sampleY * width + sampleX];
gradientX += pixelValue * sobelX[kernelIndex];
gradientY += pixelValue * sobelY[kernelIndex];
kernelIndex++;
}
}
output[y * width + x] = Math.hypot(gradientX, gradientY);
}
}
return output;
}
/**
* Compute edge projections onto X and Y axes by accumulating edge intensity.
*
* @param {Float32Array} edgeMagnitude - Edge magnitude data
* @param {number} width - Image width
* @param {number} height - Image height
* @returns {{projectionX: Float32Array, projectionY: Float32Array}} Axis projections
*/
computeEdgeProjections(edgeMagnitude, width, height) {
const projectionX = new Float32Array(width);
const projectionY = new Float32Array(height);
for (let y = 0; y < height; y++) {
let rowSum = 0;
for (let x = 0; x < width; x++) {
const edgeValue = edgeMagnitude[y * width + x];
projectionX[x] += edgeValue;
rowSum += edgeValue;
}
projectionY[y] = rowSum;
}
return { projectionX, projectionY };
}
/**
* Process a projection signal with high-pass filtering and normalization.
*
* @param {Float32Array} projection - Raw projection data
* @param {number} dimension - Image dimension (width or height)
* @returns {Float32Array} Processed and normalized signal
*/
processProjection(projection, dimension) {
const windowSize = Math.max(5, Math.floor(dimension / 50));
const highPassed = applyHighPassFilter(projection, windowSize);
return normalizeSignal(highPassed);
}
/**
* Detect the dominant period from X and Y projections using autocorrelation.
*
* @param {Float32Array} signalX - Normalized X projection
* @param {Float32Array} signalY - Normalized Y projection
* @param {number} width - Image width
* @param {number} height - Image height
* @returns {number|null} Detected period or null
*/
detectPeriodFromProjections(signalX, signalY, width, height) {
const minLagX = Math.max(8, Math.floor(width / 200));
const minLagY = Math.max(8, Math.floor(height / 200));
const maxLagX = Math.min(Math.floor(width / 2), 1024);
const maxLagY = Math.min(Math.floor(height / 2), 1024);
const autocorrX = computeAutocorrelation(signalX, minLagX, maxLagX);
const autocorrY = computeAutocorrelation(signalY, minLagY, maxLagY);
const periodX = findBestPeriodFromAutocorrelation(autocorrX);
const periodY = findBestPeriodFromAutocorrelation(autocorrY);
return combinePeriodCandidates(periodX, periodY);
}
/**
* Build the final detection result, scaling back to original image coordinates.
*
* @param {number} period - Detected period in scaled coordinates
* @param {Float32Array} signalX - X projection for offset calculation
* @param {Float32Array} signalY - Y projection for offset calculation
* @param {number} scaleFactor - Scale factor used during processing
* @returns {GridDetectionResult} Final grid detection result
*/
buildDetectionResult(period, signalX, signalY, scaleFactor) {
const offsetX = estimateGridOffset(signalX, Math.round(period));
const offsetY = estimateGridOffset(signalY, Math.round(period));
const inverseScale = 1 / scaleFactor;
return {
gridSize: period * inverseScale,
xOffset: offsetX * inverseScale,
yOffset: offsetY * inverseScale
};
}
/**
* Detect grid from manually placed points (fallback when auto-detection fails).
*
* @param {Array<{x: number, y: number}>} points - Array of grid intersection points
* @returns {GridDetectionResult} Grid detection result
*/
detectFromManualPoints(points) {
const xCoords = points.map(p => p.x);
const yCoords = points.map(p => p.y);
const minX = Math.min(...xCoords), maxX = Math.max(...xCoords);
const minY = Math.min(...yCoords), maxY = Math.max(...yCoords);
const avgSpacingX = (maxX - minX) / (points.length - 1);
const avgSpacingY = (maxY - minY) / (points.length - 1);
const gridSize = Math.round((avgSpacingX + avgSpacingY) / 2);
return {
gridSize: gridSize,
xOffset: minX % gridSize,
yOffset: minY % gridSize
};
}
}
+851
View File
@@ -0,0 +1,851 @@
/**
* Import Panel View
*
* Manages the drag-and-drop UI panel for the battlemap import workflow.
* Handles panel creation, visibility, status updates, and user interactions.
* This is a pure view component - it only manages DOM and emits events.
*
* @module ImportPanelView
*/
/**
* @typedef {Object} PanelCallbacks
* @property {Function} onCreateSceneRequested - Called when user clicks "Create Scene"
* @property {Function} onResetRequested - Called when user clicks "Reset"
* @property {Function} onCloseRequested - Called when user clicks close button
* @property {Function} onFilesDropped - Called when files are dropped on the panel
* @property {Function} onNoGridPreferenceChanged - Called when no-grid checkbox changes
*/
/** CSS selectors for frequently accessed elements */
const PANEL_SELECTORS = {
PANEL_ROOT: '#myxeliums-battlemap-drop-area',
CREATE_BUTTON: '.create-scene-button',
RESET_BUTTON: '.reset-button',
CLOSE_BUTTON: '.header-button.close',
NO_GRID_CHECKBOX: 'input.ebm-no-grid',
BACKGROUND_STATUS: '.background-status .status-value',
WALL_DATA_STATUS: '.wall-data-status .status-value',
PROGRESS_GROUP: '.progress-group',
PROGRESS_TEXT: '.ebm-progress-text',
FLOOR_LIST: '.qbi-floor-list',
FLOOR_ITEM: '.qbi-floor-item',
FILE_MATCH_DIALOG: '.qbi-file-match-dialog',
UNMATCHED_FILES: '.qbi-unmatched-files'
};
/** LocalStorage key for persisting no-grid preference */
const NO_GRID_STORAGE_KEY = 'myxeliums-battlemap:no-grid';
/**
* View class that manages the import panel DOM and user interactions.
* Follows a callback pattern for communicating events to the controller.
*/
export class ImportPanelView {
constructor() {
/** @type {boolean} Whether the panel DOM has been created */
this.isPanelCreated = false;
/** @type {boolean} Whether the panel is currently visible */
this.isCurrentlyVisible = false;
/** @type {boolean} Whether a background operation is in progress */
this.isShowingBusyState = false;
// Event callbacks - set by the controller
/** @type {Function|null} */
this.onCreateSceneRequested = null;
/** @type {Function|null} */
this.onResetRequested = null;
/** @type {Function|null} */
this.onCloseRequested = null;
/** @type {Function|null} */
this.onFilesDropped = null;
/** @type {Function|null} */
this.onNoGridPreferenceChanged = null;
/** @type {Function|null} */
this.onFloorOrderChanged = null;
/** @type {Function|null} */
this.onFloorRemoved = null;
/** @type {Function|null} */
this.onFileMatchConfirmed = null;
/** @type {Function|null} */
this.onFileMatchRequested = null;
}
/**
* Ensure the panel DOM structure exists.
* Creates the panel if it doesn't exist, removes duplicates if found.
*/
ensureCreated() {
if (this.isPanelCreated) {
return;
}
// Remove any existing panel to prevent duplicates
const existingPanel = document.getElementById('myxeliums-battlemap-drop-area');
if (existingPanel) {
existingPanel.remove();
}
// Create and insert the panel HTML
const panelHtml = this.buildPanelHtml();
document.body.insertAdjacentHTML('beforeend', panelHtml);
// Set up event listeners
this.attachButtonEventListeners();
this.attachNoGridCheckboxListener();
this.attachDragHandlers();
this.attachDropZoneHandlers();
this.isPanelCreated = true;
}
/**
* Build the complete HTML structure for the import panel.
* Uses custom styling for a clean, modern look.
*
* @returns {string} The complete panel HTML
*/
buildPanelHtml() {
const i18n = (key) => game.i18n.localize(key);
const isLevelsActive = game.modules.get('levels')?.active ?? false;
// Build floor section HTML only if Levels module is active
const floorSectionHtml = isLevelsActive ? `
<div class="qbi-floor-section">
<div class="qbi-floor-header">
<span class="qbi-floor-title">
<i class="fas fa-layer-group"></i>
${i18n('QUICKBATTLEMAP.FloorsTitle')}
</span>
<span class="qbi-floor-count">0 ${i18n('QUICKBATTLEMAP.FloorsCount')}</span>
</div>
<div class="qbi-floor-list" id="floorList">
<div class="qbi-floor-empty">
<i class="fas fa-info-circle"></i>
<span>${i18n('QUICKBATTLEMAP.FloorsEmpty')}</span>
</div>
</div>
<div class="qbi-unmatched-files" id="unmatchedFiles" style="display: none;">
<div class="qbi-unmatched-header">
<i class="fas fa-exclamation-triangle"></i>
<span>${i18n('QUICKBATTLEMAP.UnmatchedFiles')}</span>
</div>
<div class="qbi-unmatched-list"></div>
</div>
</div>
` : '';
return `
<div id="myxeliums-battlemap-drop-area" class="qbi-panel">
<header class="qbi-header">
<div class="qbi-header-title">
<i class="fas fa-map qbi-header-icon"></i>
<h4>${i18n('QUICKBATTLEMAP.DropAreaTitle')}</h4>
</div>
<button class="qbi-close-btn header-button close" type="button" aria-label="Close">
<i class="fas fa-times"></i>
</button>
</header>
<section class="qbi-content">
<form autocomplete="off">
<p class="qbi-instructions">${i18n('QUICKBATTLEMAP.DropInstructions')}</p>
<div class="qbi-dropzone" id="dropZone">
<div class="qbi-dropzone-inner">
<i class="fas fa-cloud-upload-alt qbi-dropzone-icon"></i>
<span class="qbi-dropzone-text">Drop files here</span>
<span class="qbi-dropzone-hint">Images & JSON supported</span>
</div>
</div>
<p class="qbi-instructions qbi-instructions-secondary">${i18n('QUICKBATTLEMAP.DropInstructionsMore')}</p>
${floorSectionHtml}
<div class="qbi-status-grid">
<div class="qbi-status-item">
<div class="qbi-status-indicator background-status">
<span class="status-value qbi-status-icon" data-status="pending">❌</span>
</div>
<span class="qbi-status-label">${i18n('QUICKBATTLEMAP.BackgroundStatus')}</span>
</div>
<div class="qbi-status-item">
<div class="qbi-status-indicator wall-data-status">
<span class="status-value qbi-status-icon" data-status="pending">❌</span>
</div>
<span class="qbi-status-label">${i18n('QUICKBATTLEMAP.WallDataStatus')}</span>
</div>
</div>
<div class="qbi-options">
<label class="qbi-checkbox">
<input type="checkbox" class="ebm-no-grid qbi-checkbox-input" />
<span class="qbi-checkbox-mark"></span>
<span class="qbi-checkbox-label">${i18n('QUICKBATTLEMAP.NoGridLabel')}</span>
</label>
</div>
<div class="qbi-progress progress-group">
<div class="qbi-progress-content">
<div class="qbi-spinner">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
<div class="qbi-progress-info">
<span class="ebm-progress-text qbi-progress-text">${i18n('QUICKBATTLEMAP.ProgressIdle')}</span>
<span class="qbi-progress-note">${i18n('QUICKBATTLEMAP.ProgressNote')}</span>
</div>
</div>
</div>
<footer class="qbi-footer">
<button type="button" class="qbi-btn qbi-btn-secondary reset-button">
<i class="fas fa-undo"></i>
<span>${i18n('QUICKBATTLEMAP.Reset')}</span>
</button>
<button type="button" class="qbi-btn qbi-btn-primary create-scene-button" disabled>
<i class="fas fa-plus-circle"></i>
<span>${i18n('QUICKBATTLEMAP.CreateScene')}</span>
</button>
</footer>
</form>
</section>
</div>`;
}
/**
* Attach click event listeners to the panel's action buttons.
*/
attachButtonEventListeners() {
const panel = this.getPanelElement();
if (!panel) return;
const createButton = panel.querySelector(PANEL_SELECTORS.CREATE_BUTTON);
const resetButton = panel.querySelector(PANEL_SELECTORS.RESET_BUTTON);
const closeButton = panel.querySelector(PANEL_SELECTORS.CLOSE_BUTTON);
createButton?.addEventListener('click', () => {
this.onCreateSceneRequested?.();
});
resetButton?.addEventListener('click', () => {
this.onResetRequested?.();
});
closeButton?.addEventListener('click', () => {
this.onCloseRequested?.();
});
}
/**
* Attach change listener to the no-grid checkbox.
* Loads and applies the persisted preference.
*/
attachNoGridCheckboxListener() {
const panel = this.getPanelElement();
if (!panel) return;
// Load persisted preference
const persistedValue = this.loadNoGridPreference();
const checkbox = panel.querySelector(PANEL_SELECTORS.NO_GRID_CHECKBOX);
if (checkbox) {
checkbox.checked = persistedValue;
checkbox.addEventListener('change', (event) => {
const isChecked = !!event.currentTarget.checked;
this.saveNoGridPreference(isChecked);
this.onNoGridPreferenceChanged?.(isChecked);
});
}
}
/**
* Attach drag event handlers to make the panel header draggable.
*/
attachDragHandlers() {
const panel = this.getPanelElement();
const header = panel?.querySelector('header');
if (!panel || !header) return;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let panelStartLeft = 0;
let panelStartTop = 0;
const handleMouseMove = (event) => {
if (!isDragging) return;
const deltaX = event.clientX - dragStartX;
const deltaY = event.clientY - dragStartY;
// Keep panel within viewport bounds
const newLeft = Math.max(0, panelStartLeft + deltaX);
const newTop = Math.max(0, panelStartTop + deltaY);
panel.style.left = `${newLeft}px`;
panel.style.top = `${newTop}px`;
};
const handleMouseUp = () => {
isDragging = false;
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
header.addEventListener('mousedown', (event) => {
// Only respond to left mouse button
if (event.button !== 0) return;
isDragging = true;
const panelRect = panel.getBoundingClientRect();
dragStartX = event.clientX;
dragStartY = event.clientY;
panelStartLeft = panelRect.left + window.scrollX;
panelStartTop = panelRect.top + window.scrollY;
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
});
}
/**
* Attach drag-and-drop event handlers to the drop zone.
*/
attachDropZoneHandlers() {
const dropArea = this.getPanelElement();
if (!dropArea) return;
// Prevent default browser behavior for all drag events
const preventDefaults = (event) => {
event.preventDefault();
event.stopPropagation();
};
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, preventDefaults, false);
});
// Add visual highlight during drag
['dragenter', 'dragover'].forEach(eventName => {
dropArea.addEventListener(eventName, () => {
dropArea.classList.add('highlight');
}, false);
});
// Remove highlight when drag ends
['dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, () => {
dropArea.classList.remove('highlight');
}, false);
});
// Forward drop events to callback
dropArea.addEventListener('drop', (event) => {
this.onFilesDropped?.(event);
}, false);
}
/**
* Load the no-grid preference from localStorage.
* @returns {boolean} Whether no-grid mode is enabled
*/
loadNoGridPreference() {
try {
return localStorage.getItem(NO_GRID_STORAGE_KEY) === 'true';
} catch (_error) {
return false;
}
}
/**
* Save the no-grid preference to localStorage.
* @param {boolean} isEnabled - Whether no-grid mode is enabled
*/
saveNoGridPreference(isEnabled) {
try {
localStorage.setItem(NO_GRID_STORAGE_KEY, String(isEnabled));
} catch (_error) {
// localStorage may not be available
}
}
/**
* Get the panel's root DOM element.
* @returns {HTMLElement|null} The panel element
*/
getPanelElement() {
return document.getElementById('myxeliums-battlemap-drop-area');
}
/**
* Show the import panel.
*/
show() {
this.ensureCreated();
const panel = this.getPanelElement();
if (panel) {
panel.style.display = 'block';
this.isCurrentlyVisible = true;
}
}
/**
* Hide the import panel.
*/
hide() {
const panel = this.getPanelElement();
if (panel) {
panel.style.display = 'none';
this.isCurrentlyVisible = false;
}
}
/**
* Enable or disable the Create Scene button.
* @param {boolean} isEnabled - Whether the button should be enabled
*/
setCreateButtonEnabled(isEnabled) {
const button = document.querySelector(PANEL_SELECTORS.CREATE_BUTTON);
if (button) {
button.disabled = !isEnabled;
}
}
/**
* Update the background media status indicator.
* @param {boolean} isLoaded - Whether background media is loaded
* @param {string} tooltipText - Tooltip text (usually the filename)
*/
updateBackgroundMediaStatus(isLoaded, tooltipText) {
const statusElement = document.querySelector(PANEL_SELECTORS.BACKGROUND_STATUS);
if (statusElement) {
statusElement.textContent = isLoaded ? '✅' : '❌';
statusElement.title = tooltipText || '';
statusElement.dataset.status = isLoaded ? 'complete' : 'pending';
}
}
/**
* Update the wall/grid data status indicator.
* @param {boolean} isLoaded - Whether wall data is loaded
* @param {string} tooltipText - Tooltip text (usually the filename or "Auto-detected")
*/
updateWallDataStatus(isLoaded, tooltipText) {
const statusElement = document.querySelector(PANEL_SELECTORS.WALL_DATA_STATUS);
if (statusElement) {
statusElement.textContent = isLoaded ? '✅' : '❌';
statusElement.title = tooltipText || '';
statusElement.dataset.status = isLoaded ? 'complete' : 'pending';
}
}
/**
* Show the progress/busy indicator with a message.
* @param {string} statusMessage - Message to display
*/
showBusyState(statusMessage) {
const progressGroup = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_GROUP}`);
const progressText = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_TEXT}`);
if (progressGroup) {
progressGroup.style.display = '';
}
if (progressText && statusMessage) {
progressText.textContent = statusMessage;
}
this.isShowingBusyState = true;
}
/**
* Hide the progress/busy indicator.
*/
clearBusyState() {
const progressGroup = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_GROUP}`);
const progressText = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_TEXT}`);
if (progressGroup) {
progressGroup.style.display = 'none';
}
if (progressText) {
progressText.textContent = game.i18n.localize('QUICKBATTLEMAP.ProgressIdle');
}
this.isShowingBusyState = false;
}
/**
* Reset all status indicators to their default (empty) state.
* @param {boolean} persistedNoGridValue - Whether to check the no-grid checkbox
*/
resetAllStatuses(persistedNoGridValue) {
this.updateBackgroundMediaStatus(false, '');
this.updateWallDataStatus(false, '');
this.setCreateButtonEnabled(false);
// Hide and reset progress indicator
const progressGroup = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_GROUP}`);
const progressText = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_TEXT}`);
if (progressGroup) {
progressGroup.style.display = 'none';
}
if (progressText) {
progressText.textContent = game.i18n.localize('QUICKBATTLEMAP.ProgressIdle');
}
// Restore no-grid checkbox to persisted value
const noGridCheckbox = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.NO_GRID_CHECKBOX}`);
if (noGridCheckbox) {
noGridCheckbox.checked = !!persistedNoGridValue;
}
}
/**
* Get the current state of the no-grid checkbox.
* @returns {boolean} Whether no-grid mode is selected
*/
getNoGridCheckboxState() {
const checkbox = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.NO_GRID_CHECKBOX}`);
return !!checkbox?.checked;
}
/**
* Set the state of the no-grid checkbox.
* @param {boolean} isChecked - Whether the checkbox should be checked
*/
setNoGridCheckboxState(isChecked) {
const checkbox = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.NO_GRID_CHECKBOX}`);
if (checkbox) {
checkbox.checked = !!isChecked;
}
}
/**
* Render the floor list from the provided floors data.
* @param {Array} floors - Array of floor objects with id, name, mediaFile, jsonFile
*/
renderFloorList(floors) {
const floorList = document.querySelector(PANEL_SELECTORS.FLOOR_LIST);
const floorCount = document.querySelector('.qbi-floor-count');
const i18n = (key) => game.i18n.localize(key);
if (!floorList) return;
// Update floor count
if (floorCount) {
floorCount.textContent = `${floors.length} ${i18n('QUICKBATTLEMAP.FloorsCount')}`;
}
// Show empty state if no floors
if (floors.length === 0) {
floorList.innerHTML = `
<div class="qbi-floor-empty">
<i class="fas fa-info-circle"></i>
<span>${i18n('QUICKBATTLEMAP.FloorsEmpty')}</span>
</div>`;
return;
}
// Render floor items
floorList.innerHTML = floors.map((floor, index) => `
<div class="qbi-floor-item" data-floor-id="${floor.id}" draggable="true">
<div class="qbi-floor-drag-handle">
<i class="fas fa-grip-vertical"></i>
</div>
<div class="qbi-floor-info">
<div class="qbi-floor-name">
<span class="qbi-floor-level">${i18n('QUICKBATTLEMAP.FloorLevel')} ${index + 1}</span>
<span class="qbi-floor-filename" title="${floor.mediaFile?.name || ''}">${this.truncateFilename(floor.mediaFile?.name || i18n('QUICKBATTLEMAP.NoMedia'))}</span>
</div>
<div class="qbi-floor-json ${floor.jsonFile ? 'has-json' : 'no-json'}">
<i class="fas ${floor.jsonFile ? 'fa-check-circle' : 'fa-times-circle'}"></i>
<span>${floor.jsonFile ? this.truncateFilename(floor.jsonFile.name) : i18n('QUICKBATTLEMAP.NoJson')}</span>
</div>
</div>
<div class="qbi-floor-actions">
<button type="button" class="qbi-floor-btn qbi-floor-move-up" title="${i18n('QUICKBATTLEMAP.MoveUp')}" ${index === 0 ? 'disabled' : ''}>
<i class="fas fa-chevron-up"></i>
</button>
<button type="button" class="qbi-floor-btn qbi-floor-move-down" title="${i18n('QUICKBATTLEMAP.MoveDown')}" ${index === floors.length - 1 ? 'disabled' : ''}>
<i class="fas fa-chevron-down"></i>
</button>
<button type="button" class="qbi-floor-btn qbi-floor-remove" title="${i18n('QUICKBATTLEMAP.RemoveFloor')}">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
`).join('');
// Attach event listeners to floor items
this.attachFloorItemEventListeners();
}
/**
* Truncate a filename for display.
* @param {string} filename - The filename to truncate
* @param {number} maxLength - Maximum length before truncation
* @returns {string} Truncated filename
*/
truncateFilename(filename, maxLength = 25) {
if (!filename || filename.length <= maxLength) return filename;
const extension = filename.split('.').pop();
const nameWithoutExt = filename.slice(0, filename.lastIndexOf('.'));
const truncatedName = nameWithoutExt.slice(0, maxLength - extension.length - 4) + '...';
return `${truncatedName}.${extension}`;
}
/**
* Attach event listeners to floor item buttons.
*/
attachFloorItemEventListeners() {
const floorList = document.querySelector(PANEL_SELECTORS.FLOOR_LIST);
if (!floorList) return;
// Move up buttons
floorList.querySelectorAll('.qbi-floor-move-up').forEach(btn => {
btn.addEventListener('click', (e) => {
const floorItem = e.currentTarget.closest(PANEL_SELECTORS.FLOOR_ITEM);
const floorId = floorItem?.dataset.floorId;
if (floorId) this.onFloorOrderChanged?.('up', floorId);
});
});
// Move down buttons
floorList.querySelectorAll('.qbi-floor-move-down').forEach(btn => {
btn.addEventListener('click', (e) => {
const floorItem = e.currentTarget.closest(PANEL_SELECTORS.FLOOR_ITEM);
const floorId = floorItem?.dataset.floorId;
if (floorId) this.onFloorOrderChanged?.('down', floorId);
});
});
// Remove buttons
floorList.querySelectorAll('.qbi-floor-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
const floorItem = e.currentTarget.closest(PANEL_SELECTORS.FLOOR_ITEM);
const floorId = floorItem?.dataset.floorId;
if (floorId) this.onFloorRemoved?.(floorId);
});
});
// Setup drag and drop for reordering
this.setupFloorDragAndDrop();
}
/**
* Setup drag and drop functionality for floor reordering.
*/
setupFloorDragAndDrop() {
const floorList = document.querySelector(PANEL_SELECTORS.FLOOR_LIST);
if (!floorList) return;
let draggedItem = null;
floorList.querySelectorAll(PANEL_SELECTORS.FLOOR_ITEM).forEach(item => {
item.addEventListener('dragstart', (e) => {
draggedItem = item;
item.classList.add('qbi-floor-dragging');
e.dataTransfer.effectAllowed = 'move';
});
item.addEventListener('dragend', () => {
if (draggedItem) {
draggedItem.classList.remove('qbi-floor-dragging');
draggedItem = null;
}
floorList.querySelectorAll(PANEL_SELECTORS.FLOOR_ITEM).forEach(i => {
i.classList.remove('qbi-floor-drag-over');
});
});
item.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (item !== draggedItem) {
item.classList.add('qbi-floor-drag-over');
}
});
item.addEventListener('dragleave', () => {
item.classList.remove('qbi-floor-drag-over');
});
item.addEventListener('drop', (e) => {
e.preventDefault();
item.classList.remove('qbi-floor-drag-over');
if (draggedItem && item !== draggedItem) {
const fromId = draggedItem.dataset.floorId;
const toId = item.dataset.floorId;
this.onFloorOrderChanged?.('reorder', fromId, toId);
}
});
});
}
/**
* Show unmatched files that need user attention.
* @param {Array} unmatchedMedia - Array of unmatched media files
* @param {Array} unmatchedJson - Array of unmatched JSON files
*/
showUnmatchedFiles(unmatchedMedia, unmatchedJson) {
const container = document.querySelector(PANEL_SELECTORS.UNMATCHED_FILES);
const listElement = container?.querySelector('.qbi-unmatched-list');
const i18n = (key) => game.i18n.localize(key);
if (!container || !listElement) return;
const hasUnmatched = unmatchedMedia.length > 0 || unmatchedJson.length > 0;
container.style.display = hasUnmatched ? '' : 'none';
if (!hasUnmatched) return;
let html = '';
if (unmatchedMedia.length > 0) {
html += `
<div class="qbi-unmatched-group">
<span class="qbi-unmatched-label"><i class="fas fa-image"></i> ${i18n('QUICKBATTLEMAP.UnmatchedMedia')}</span>
${unmatchedMedia.map(file => `
<div class="qbi-unmatched-item" data-file-name="${file.name}" data-file-type="media">
<span class="qbi-unmatched-name" title="${file.name}">${this.truncateFilename(file.name)}</span>
<button type="button" class="qbi-unmatched-assign" title="${i18n('QUICKBATTLEMAP.AssignToFloor')}">
<i class="fas fa-link"></i>
</button>
</div>
`).join('')}
</div>`;
}
if (unmatchedJson.length > 0) {
html += `
<div class="qbi-unmatched-group">
<span class="qbi-unmatched-label"><i class="fas fa-file-code"></i> ${i18n('QUICKBATTLEMAP.UnmatchedJson')}</span>
${unmatchedJson.map(file => `
<div class="qbi-unmatched-item" data-file-name="${file.name}" data-file-type="json">
<span class="qbi-unmatched-name" title="${file.name}">${this.truncateFilename(file.name)}</span>
<button type="button" class="qbi-unmatched-assign" title="${i18n('QUICKBATTLEMAP.AssignToFloor')}">
<i class="fas fa-link"></i>
</button>
</div>
`).join('')}
</div>`;
}
listElement.innerHTML = html;
// Attach click handlers for assign buttons
listElement.querySelectorAll('.qbi-unmatched-assign').forEach(btn => {
btn.addEventListener('click', (e) => {
const item = e.currentTarget.closest('.qbi-unmatched-item');
const fileName = item?.dataset.fileName;
const fileType = item?.dataset.fileType;
if (fileName && fileType) {
this.showFileMatchDialog(fileName, fileType);
}
});
});
}
/**
* Hide unmatched files section.
*/
hideUnmatchedFiles() {
const container = document.querySelector(PANEL_SELECTORS.UNMATCHED_FILES);
if (container) {
container.style.display = 'none';
}
}
/**
* Show a dialog to match a file to a floor.
* @param {string} fileName - The file to match
* @param {string} fileType - 'media' or 'json'
*/
showFileMatchDialog(fileName, fileType) {
// This will be handled by the controller opening a proper dialog
this.onFileMatchRequested?.(fileName, fileType);
}
/**
* Show a dialog for the user to match files to floors.
* @param {Array} floors - Current floors list
* @param {string} fileName - File to match
* @param {string} fileType - 'media' or 'json'
* @returns {Promise<string|null>} Selected floor ID or null if cancelled
*/
async promptFloorSelection(floors, fileName, fileType) {
const i18n = (key) => game.i18n.localize(key);
return new Promise((resolve) => {
const dialogContent = `
<div class="qbi-match-dialog-content">
<p>${i18n('QUICKBATTLEMAP.SelectFloorFor')} <strong>${fileName}</strong></p>
<select class="qbi-floor-select">
<option value="">${i18n('QUICKBATTLEMAP.SelectFloor')}</option>
${floors.map((floor, index) => `
<option value="${floor.id}">${i18n('QUICKBATTLEMAP.FloorLevel')} ${index + 1} - ${floor.mediaFile?.name || i18n('QUICKBATTLEMAP.NoMedia')}</option>
`).join('')}
${fileType === 'media' ? `<option value="__new__">${i18n('QUICKBATTLEMAP.CreateNewFloor')}</option>` : ''}
</select>
</div>
`;
new Dialog({
title: i18n('QUICKBATTLEMAP.MatchFileTitle'),
content: dialogContent,
buttons: {
confirm: {
icon: '<i class="fas fa-check"></i>',
label: i18n('QUICKBATTLEMAP.Confirm'),
callback: (html) => {
const select = html.find('.qbi-floor-select')[0];
resolve(select?.value || null);
}
},
cancel: {
icon: '<i class="fas fa-times"></i>',
label: i18n('QUICKBATTLEMAP.Cancel'),
callback: () => resolve(null)
}
},
default: 'confirm'
}).render(true);
});
}
/**
* Clear the floor list display.
*/
clearFloorList() {
const i18n = (key) => game.i18n.localize(key);
const floorList = document.querySelector(PANEL_SELECTORS.FLOOR_LIST);
const floorCount = document.querySelector('.qbi-floor-count');
if (floorList) {
floorList.innerHTML = `
<div class="qbi-floor-empty">
<i class="fas fa-info-circle"></i>
<span>${i18n('QUICKBATTLEMAP.FloorsEmpty')}</span>
</div>`;
}
if (floorCount) {
floorCount.textContent = `0 ${i18n('QUICKBATTLEMAP.FloorsCount')}`;
}
this.hideUnmatchedFiles();
}
}
+172
View File
@@ -0,0 +1,172 @@
/**
* Media Storage Service
*
* Handles uploading background media files (images and videos) to Foundry VTT's
* file storage system. Manages directory creation and file organization.
*
* @module MediaStorageService
*/
/** Module identifier for console logging */
const MODULE_LOG_PREFIX = 'Quick Battlemap Importer';
/** Storage source for file operations (Foundry's data directory) */
const STORAGE_SOURCE = 'data';
/**
* @typedef {Object} BackgroundMediaData
* @property {string} data - Base64 data URL or blob URL for the media
* @property {string} filename - Original filename of the media
* @property {File} [file] - The original File object (if available)
* @property {boolean} isVideo - Whether the media is a video file
*/
/**
* @typedef {Object} UploadResult
* @property {string} path - The path to the uploaded file in Foundry's storage
*/
/**
* Service class responsible for uploading media files to Foundry's storage.
* Handles directory creation and file upload with error handling.
*/
export class MediaStorageService {
/**
* Upload a background media file (image or video) to Foundry's storage.
* Creates the target directory if it doesn't exist.
*
* @param {BackgroundMediaData} mediaData - The media data to upload
* @param {string} worldId - The current world's identifier for directory naming
* @returns {Promise<UploadResult|null>} Upload result with file path, or null on failure
*
* @example
* const storage = new MediaStorageService();
* const result = await storage.uploadBackgroundMedia(mediaData, game.world.id);
* if (result?.path) {
* // Use result.path as the scene background
* }
*/
async uploadBackgroundMedia(mediaData, worldId) {
try {
// Get or create a File object from the media data
const fileToUpload = await this.prepareFileForUpload(mediaData);
// Build the target directory path
const targetDirectory = this.buildTargetDirectory(worldId);
// Ensure the directory exists
await this.ensureDirectoryExists(STORAGE_SOURCE, targetDirectory);
// Upload the file
const uploadResult = await FilePicker.upload(
STORAGE_SOURCE,
targetDirectory,
fileToUpload,
{ overwrite: true }
);
return { path: uploadResult?.path };
} catch (uploadError) {
this.handleUploadError(uploadError);
return null;
}
}
/**
* Prepare a File object for upload from media data.
* If a File object already exists, uses it directly.
* Otherwise, fetches the data URL and converts to a File.
*
* @param {BackgroundMediaData} mediaData - The media data
* @returns {Promise<File>} A File object ready for upload
*/
async prepareFileForUpload(mediaData) {
// If we already have a File object, use it directly
if (mediaData.file) {
return mediaData.file;
}
// Otherwise, fetch the data URL and create a File from it
const response = await fetch(mediaData.data);
const blobData = await response.blob();
// Determine MIME type from blob or based on media type
const mimeType = blobData.type || this.getDefaultMimeType(mediaData.isVideo);
return new File([blobData], mediaData.filename, { type: mimeType });
}
/**
* Get the default MIME type based on whether the media is video or image.
*
* @param {boolean} isVideo - Whether the media is a video
* @returns {string} The default MIME type
*/
getDefaultMimeType(isVideo) {
return isVideo ? 'video/webm' : 'image/png';
}
/**
* Build the target directory path for storing battlemap media.
* Uses the world ID to organize files by world.
*
* @param {string} worldId - The world identifier
* @returns {string} The target directory path
*/
buildTargetDirectory(worldId) {
return `worlds/${worldId}/quick-battlemap`;
}
/**
* Ensure a directory exists in Foundry's file storage.
* Creates the directory if it doesn't exist, handling race conditions.
*
* @param {string} storageSource - The storage source (typically 'data')
* @param {string} directoryPath - The path to the directory
* @throws {Error} If directory creation fails for reasons other than already existing
*/
async ensureDirectoryExists(storageSource, directoryPath) {
try {
// Try to browse the directory to see if it exists
await FilePicker.browse(storageSource, directoryPath);
} catch (_browseError) {
// Directory doesn't exist, try to create it
await this.createDirectorySafely(storageSource, directoryPath);
}
}
/**
* Safely create a directory, handling the case where it already exists.
* Multiple simultaneous requests might try to create the same directory.
*
* @param {string} storageSource - The storage source
* @param {string} directoryPath - The path to create
*/
async createDirectorySafely(storageSource, directoryPath) {
try {
await FilePicker.createDirectory(storageSource, directoryPath, {});
} catch (createError) {
// EEXIST means directory was created by another request - that's fine
const errorMessage = String(createError || '');
if (!errorMessage.includes('EEXIST')) {
throw createError;
}
}
}
/**
* Handle upload errors by logging and notifying the user.
*
* @param {Error} uploadError - The error that occurred
*/
handleUploadError(uploadError) {
console.error(`${MODULE_LOG_PREFIX} | Upload failed:`, uploadError);
const localizedMessage = game.i18n.localize('QUICKBATTLEMAP.UploadFailed');
const errorDetail = uploadError?.message ?? String(uploadError);
ui.notifications.error(`${localizedMessage}: ${errorDetail}`);
}
}
+638
View File
@@ -0,0 +1,638 @@
/**
* Scene Builder
*
* Handles the creation and configuration of Foundry VTT Scene documents.
* Extracts scene-specific logic for cleaner separation from the import controller.
*
* @module SceneBuilder
*/
/** Module identifier for console logging */
const MODULE_LOG_PREFIX = 'Quick Battlemap Importer';
/** Maximum time to wait for canvas to be ready (milliseconds) */
const CANVAS_READY_TIMEOUT_MS = 8000;
/** Interval between canvas ready checks (milliseconds) */
const CANVAS_READY_CHECK_INTERVAL_MS = 100;
/**
* @typedef {Object} SceneCreationOptions
* @property {string} backgroundPath - Path to the uploaded background media
* @property {string} sceneName - Name for the new scene
* @property {number} width - Scene width in pixels
* @property {number} height - Scene height in pixels
* @property {number} [padding=0] - Scene padding multiplier
* @property {string} [backgroundColor='#000000'] - Background color
* @property {boolean} [globalLight=false] - Enable global illumination
* @property {number} [darkness=0] - Darkness level (0-1)
*/
/**
* @typedef {Object} GridSettings
* @property {number} size - Grid cell size in pixels
* @property {number} type - Grid type (0=none, 1=square, etc.)
* @property {number} distance - Distance per grid cell
* @property {string} units - Distance units
* @property {string} color - Grid line color
* @property {number} alpha - Grid line opacity
* @property {Object} offset - Grid offset
* @property {number} offset.x - Horizontal offset
* @property {number} offset.y - Vertical offset
*/
/**
* Service class responsible for building and configuring Foundry scenes.
* Handles scene creation, grid settings, walls, and lights.
*/
export class SceneBuilder {
/**
* Create a new scene builder instance.
*
* @param {boolean} [enableDebugLogging=false] - Whether to log debug information
*/
constructor(enableDebugLogging = false) {
/** @type {boolean} Enable verbose console logging */
this.isDebugLoggingEnabled = enableDebugLogging;
}
/**
* Create a new Foundry Scene document with the specified options.
*
* @param {SceneCreationOptions} options - Scene creation options
* @returns {Promise<Scene>} The created Scene document
*
* @example
* const builder = new SceneBuilder();
* const scene = await builder.createScene({
* backgroundPath: 'worlds/myworld/maps/dungeon.png',
* sceneName: 'Dungeon Level 1',
* width: 2048,
* height: 2048
* });
*/
async createScene(options) {
const sceneDocumentData = {
name: options.sceneName,
img: options.backgroundPath,
background: { src: options.backgroundPath },
width: options.width,
height: options.height,
padding: options.padding ?? 0,
backgroundColor: options.backgroundColor ?? '#000000',
globalLight: options.globalLight ?? false,
darkness: options.darkness ?? 0
};
const createdScene = await Scene.create(sceneDocumentData);
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Created scene:`, createdScene.name);
}
return createdScene;
}
/**
* Activate a scene and wait for the canvas to be fully ready.
*
* @param {Scene} scene - The scene to activate
* @returns {Promise<void>} Resolves when canvas is ready
*/
async activateAndWaitForCanvas(scene) {
await scene.activate();
await this.waitForCanvasReady(scene);
}
/**
* Wait for the canvas to be fully ready after scene activation.
* Times out after CANVAS_READY_TIMEOUT_MS to prevent infinite waiting.
*
* @param {Scene} targetScene - The scene to wait for
* @returns {Promise<void>} Resolves when canvas is ready or timeout reached
*/
async waitForCanvasReady(targetScene) {
const timeoutDeadline = Date.now() + CANVAS_READY_TIMEOUT_MS;
while (Date.now() < timeoutDeadline) {
const isCanvasReady = this.checkCanvasReadyState(targetScene);
if (isCanvasReady) {
return;
}
await this.delay(CANVAS_READY_CHECK_INTERVAL_MS);
}
if (this.isDebugLoggingEnabled) {
console.warn(`${MODULE_LOG_PREFIX} | Canvas ready timeout reached`);
}
}
/**
* Check if the canvas is fully initialized for a scene.
*
* @param {Scene} targetScene - The scene to check
* @returns {boolean} True if canvas is ready
*/
checkCanvasReadyState(targetScene) {
return (
canvas?.ready &&
canvas?.scene?.id === targetScene.id &&
canvas?.walls?.initialized !== false &&
canvas?.lighting?.initialized !== false
);
}
/**
* Apply grid settings to a scene, handling different Foundry versions.
*
* @param {Scene} scene - The scene to update
* @param {GridSettings} gridSettings - Grid configuration to apply
* @param {boolean} [useNoGridMode=false] - Override grid type to 0 (none)
*/
async applyGridSettings(scene, gridSettings, useNoGridMode = false) {
// Override grid type if no-grid mode is enabled
const effectiveGridSettings = { ...gridSettings };
if (useNoGridMode) {
effectiveGridSettings.type = 0;
}
const sceneSnapshot = duplicate(scene.toObject());
const usesObjectGridFormat = typeof sceneSnapshot.grid === 'object';
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Scene grid snapshot:`, sceneSnapshot.grid);
}
if (usesObjectGridFormat) {
await this.applyObjectGridSettings(scene, sceneSnapshot, effectiveGridSettings);
} else {
await this.applyLegacyGridSettings(scene, effectiveGridSettings);
}
if (this.isDebugLoggingEnabled) {
const updatedSnapshot = duplicate(scene.toObject());
console.log(`${MODULE_LOG_PREFIX} | Grid after update:`, updatedSnapshot.grid);
}
}
/**
* Apply grid settings using modern object format (Foundry v10+).
* Falls back to legacy format if update fails.
*
* @param {Scene} scene - The scene to update
* @param {Object} snapshot - Current scene data snapshot
* @param {GridSettings} gridSettings - Grid settings to apply
*/
async applyObjectGridSettings(scene, snapshot, gridSettings) {
const gridUpdateData = {
...(snapshot.grid || {}),
size: gridSettings.size,
type: gridSettings.type,
distance: gridSettings.distance,
units: gridSettings.units,
color: gridSettings.color,
alpha: gridSettings.alpha,
offset: {
x: gridSettings.offset?.x ?? 0,
y: gridSettings.offset?.y ?? 0
}
};
try {
await scene.update({ grid: gridUpdateData });
} catch (updateError) {
console.warn(`${MODULE_LOG_PREFIX} | Grid object update failed; using legacy format`, updateError);
await this.applyLegacyGridSettings(scene, gridSettings);
}
}
/**
* Apply grid settings using legacy flat property format.
* Used for older Foundry versions or as fallback.
*
* @param {Scene} scene - The scene to update
* @param {GridSettings} gridSettings - Grid settings to apply
*/
async applyLegacyGridSettings(scene, gridSettings) {
await scene.update({
grid: gridSettings.size,
gridType: gridSettings.type,
gridDistance: gridSettings.distance,
gridUnits: gridSettings.units,
gridColor: gridSettings.color,
gridAlpha: gridSettings.alpha,
shiftX: gridSettings.offset?.x ?? 0,
shiftY: gridSettings.offset?.y ?? 0
});
}
/**
* Create wall documents in a scene.
* Filters invalid walls and retries once on failure.
*
* @param {Scene} scene - The scene to add walls to
* @param {Array} wallsData - Array of wall document data
* @returns {Promise<void>}
*/
async createWalls(scene, wallsData) {
const validWalls = this.filterValidWalls(wallsData || []);
if (!validWalls.length) {
return;
}
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Creating ${validWalls.length} walls`);
}
const wallCountBefore = scene.walls?.size ?? 0;
try {
await scene.createEmbeddedDocuments('Wall', validWalls);
} catch (firstAttemptError) {
console.warn(`${MODULE_LOG_PREFIX} | Wall creation failed, retrying...`, firstAttemptError);
await this.retryWallCreation(scene, validWalls, wallCountBefore);
}
}
/**
* Filter wall data to remove invalid entries.
* Walls must have valid coordinates and non-zero length.
*
* @param {Array} wallsData - Raw wall data array
* @returns {Array} Filtered array of valid walls
*/
filterValidWalls(wallsData) {
return wallsData.filter(wall => {
// Must have coordinate array with at least 4 values
if (!Array.isArray(wall.c) || wall.c.length < 4) {
return false;
}
const [startX, startY, endX, endY] = wall.c.map(n => Number(n));
const coordinates = [startX, startY, endX, endY];
// All coordinates must be finite numbers
if (coordinates.some(coord => !Number.isFinite(coord))) {
return false;
}
// Wall must have non-zero length (not a point)
if (startX === endX && startY === endY) {
return false;
}
return true;
});
}
/**
* Retry wall creation after waiting for canvas stability.
*
* @param {Scene} scene - The scene to add walls to
* @param {Array} validWalls - Valid wall data array
* @param {number} wallCountBefore - Wall count before first attempt
*/
async retryWallCreation(scene, validWalls, wallCountBefore) {
await this.waitForCanvasReady(scene);
await this.delay(200);
try {
await scene.createEmbeddedDocuments('Wall', validWalls);
} catch (retryError) {
const wallCountAfter = scene.walls?.size ?? 0;
if (wallCountAfter > wallCountBefore) {
// Walls were actually created despite the error
console.warn(`${MODULE_LOG_PREFIX} | Walls created despite error`);
} else {
console.error(`${MODULE_LOG_PREFIX} | Failed to create walls:`, validWalls.slice(0, 5));
console.error(retryError);
ui.notifications.warn('Some walls could not be created. See console.');
}
}
}
/**
* Create ambient light documents in a scene.
*
* @param {Scene} scene - The scene to add lights to
* @param {Array} lightsData - Array of light document data
* @returns {Promise<void>}
*/
async createLights(scene, lightsData) {
const lights = lightsData || [];
if (!lights.length) {
return;
}
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Creating ${lights.length} lights`);
}
try {
await scene.createEmbeddedDocuments('AmbientLight', lights);
} catch (creationError) {
console.error(`${MODULE_LOG_PREFIX} | Failed to create lights:`, lights.slice(0, 5));
console.error(creationError);
ui.notifications.warn('Some lights could not be created. See console.');
}
}
/**
* Create floor tiles for multi-floor scenes.
* Each additional floor is created as an overhead tile that can be toggled.
*
* @param {Scene} scene - The scene to add floor tiles to
* @param {Array} additionalFloors - Array of floor data (excluding base floor)
* @param {Object} baseDimensions - Dimensions of the base floor
* @param {Object} dataNormalizer - Data normalizer instance for processing JSON
* @returns {Promise<void>}
*/
async createFloorTiles(scene, additionalFloors, baseDimensions, dataNormalizer) {
if (!additionalFloors || additionalFloors.length === 0) {
return;
}
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Creating ${additionalFloors.length} floor tiles`);
}
const tileDocuments = [];
const wallDocuments = [];
const lightDocuments = [];
for (let i = 0; i < additionalFloors.length; i++) {
const floor = additionalFloors[i];
const floorLevel = i + 2; // Floors start at 2 (1 is the base)
// Create tile for this floor
const tileData = {
texture: {
src: floor.uploadedPath
},
x: 0,
y: 0,
width: baseDimensions.width || scene.width,
height: baseDimensions.height || scene.height,
overhead: true,
roof: false,
hidden: true, // Start hidden, user can toggle
sort: 1000 + (i * 100), // Ensure proper z-ordering
flags: {
'quick-battlemap-importer': {
floorLevel: floorLevel,
floorName: floor.mediaData?.filename || `Floor ${floorLevel}`
}
}
};
tileDocuments.push(tileData);
// Process walls and lights for this floor if JSON data exists
if (floor.jsonData) {
const normalizedData = dataNormalizer.normalizeToFoundryFormat(floor.jsonData);
// Add walls with floor level flag
if (normalizedData.walls && normalizedData.walls.length > 0) {
const floorWalls = normalizedData.walls.map(wall => ({
...wall,
flags: {
...wall.flags,
'quick-battlemap-importer': {
floorLevel: floorLevel
}
}
}));
wallDocuments.push(...this.filterValidWalls(floorWalls));
}
// Add lights with floor level flag
if (normalizedData.lights && normalizedData.lights.length > 0) {
const floorLights = normalizedData.lights.map(light => ({
...light,
hidden: true, // Start hidden like the tile
flags: {
...light.flags,
'quick-battlemap-importer': {
floorLevel: floorLevel
}
}
}));
lightDocuments.push(...floorLights);
}
}
}
// Create all tiles
try {
await scene.createEmbeddedDocuments('Tile', tileDocuments);
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Created ${tileDocuments.length} floor tiles`);
}
} catch (error) {
console.error(`${MODULE_LOG_PREFIX} | Failed to create floor tiles:`, error);
ui.notifications.warn('Some floor tiles could not be created. See console.');
}
// Create walls for additional floors
if (wallDocuments.length > 0) {
try {
await scene.createEmbeddedDocuments('Wall', wallDocuments);
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Created ${wallDocuments.length} walls for additional floors`);
}
} catch (error) {
console.error(`${MODULE_LOG_PREFIX} | Failed to create walls for floors:`, error);
}
}
// Create lights for additional floors
if (lightDocuments.length > 0) {
try {
await scene.createEmbeddedDocuments('AmbientLight', lightDocuments);
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Created ${lightDocuments.length} lights for additional floors`);
}
} catch (error) {
console.error(`${MODULE_LOG_PREFIX} | Failed to create lights for floors:`, error);
}
}
}
/**
* Utility method to create a delay.
*
* @param {number} milliseconds - Duration to wait
* @returns {Promise<void>} Resolves after the delay
*/
delay(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
/**
* Create walls with elevation for Levels module compatibility.
*
* @param {Scene} scene - The scene to add walls to
* @param {Array} wallsData - Array of wall document data
* @param {number} bottom - Bottom elevation for the walls
* @param {number} top - Top elevation for the walls
* @returns {Promise<void>}
*/
async createWallsWithElevation(scene, wallsData, bottom, top) {
const validWalls = this.filterValidWalls(wallsData || []);
if (!validWalls.length) {
return;
}
// Add wall-height flags for Levels/Wall Height Enhanced compatibility
const wallsWithElevation = validWalls.map(wall => ({
...wall,
flags: {
...wall.flags,
'wall-height': {
bottom: bottom,
top: top
}
}
}));
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Creating ${wallsWithElevation.length} walls with elevation ${bottom}-${top}`);
}
try {
await scene.createEmbeddedDocuments('Wall', wallsWithElevation);
} catch (error) {
console.error(`${MODULE_LOG_PREFIX} | Failed to create walls with elevation:`, error);
}
}
/**
* Create lights with elevation for Levels module compatibility.
*
* @param {Scene} scene - The scene to add lights to
* @param {Array} lightsData - Array of light document data
* @param {number} bottom - Bottom elevation for the lights
* @param {number} top - Top elevation for the lights
* @returns {Promise<void>}
*/
async createLightsWithElevation(scene, lightsData, bottom, top) {
const lights = lightsData || [];
if (!lights.length) {
return;
}
// Add elevation and Levels flags
const lightsWithElevation = lights.map(light => ({
...light,
elevation: bottom,
flags: {
...light.flags,
levels: {
rangeTop: top
}
}
}));
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Creating ${lightsWithElevation.length} lights with elevation ${bottom}-${top}`);
}
try {
await scene.createEmbeddedDocuments('AmbientLight', lightsWithElevation);
} catch (error) {
console.error(`${MODULE_LOG_PREFIX} | Failed to create lights with elevation:`, error);
}
}
/**
* Create floor tiles for multi-floor scenes using Levels module format.
* Each additional floor is created as a tile with proper elevation.
*
* @param {Scene} scene - The scene to add floor tiles to
* @param {Array} additionalFloors - Array of floor data (excluding base floor)
* @param {Object} baseDimensions - Dimensions of the base floor
* @param {Object} dataNormalizer - Data normalizer instance for processing JSON
* @param {Array<number>} floorElevations - Array of elevation values for each floor
* @param {number} floorHeight - Height of each floor in grid units
* @returns {Promise<void>}
*/
async createLevelsFloorTiles(scene, additionalFloors, baseDimensions, dataNormalizer, floorElevations, floorHeight) {
if (!additionalFloors || additionalFloors.length === 0) {
return;
}
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Creating ${additionalFloors.length} Levels floor tiles`);
}
const tileDocuments = [];
for (let i = 0; i < additionalFloors.length; i++) {
const floor = additionalFloors[i];
const floorLevel = i + 2; // Floors start at 2 (1 is the base)
const elevation = floorElevations[i];
const rangeTop = elevation + floorHeight * 2 - 1;
// Create tile for this floor with Levels flags
// Using overhead: false and proper Levels flags to avoid fade/zoom behavior
const tileData = {
texture: {
src: floor.uploadedPath
},
x: 0,
y: 0,
width: baseDimensions.width || scene.width,
height: baseDimensions.height || scene.height,
overhead: false, // Not overhead - this is a floor tile
roof: false,
occlusion: { mode: 0 }, // No occlusion - controlled by Levels
elevation: elevation,
sort: 100 + (i * 10), // Lower sort order for floor tiles
flags: {
levels: {
rangeTop: rangeTop,
showIfAbove: false, // Don't show when above this floor
noCollision: true, // No 3D collision for floor tiles
noFogHide: true // Don't hide in fog
}
}
};
tileDocuments.push(tileData);
// Process walls and lights for this floor if JSON data exists
if (floor.jsonData) {
const normalizedData = dataNormalizer.normalizeToFoundryFormat(floor.jsonData);
// Create walls with elevation
if (normalizedData.walls && normalizedData.walls.length > 0) {
await this.createWallsWithElevation(scene, normalizedData.walls, elevation, rangeTop);
}
// Create lights with elevation
if (normalizedData.lights && normalizedData.lights.length > 0) {
await this.createLightsWithElevation(scene, normalizedData.lights, elevation, rangeTop);
}
}
}
// Create all tiles
try {
await scene.createEmbeddedDocuments('Tile', tileDocuments);
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Created ${tileDocuments.length} Levels floor tiles`);
}
} catch (error) {
console.error(`${MODULE_LOG_PREFIX} | Failed to create Levels floor tiles:`, error);
ui.notifications.warn('Some floor tiles could not be created. See console.');
}
}
}
+441
View File
@@ -0,0 +1,441 @@
/**
* Scene Data Normalizer
*
* Transforms imported scene configuration data (from JSON exports like Dungeon Alchemist)
* into Foundry VTT's expected document format. Handles various input formats and
* provides sensible defaults for missing values.
*
* @module SceneDataNormalizer
*/
/**
* @typedef {Object} NormalizedGridSettings
* @property {number} size - Grid cell size in pixels
* @property {number} type - Grid type (0=none, 1=square, 2=hex-row, 3=hex-col)
* @property {number} distance - Real-world distance per grid cell
* @property {string} units - Unit of measurement (ft, m, etc.)
* @property {number} alpha - Grid line opacity (0-1)
* @property {string} color - Grid line color (hex)
* @property {Object} offset - Grid offset for alignment
* @property {number} offset.x - Horizontal offset in pixels
* @property {number} offset.y - Vertical offset in pixels
*/
/**
* @typedef {Object} NormalizedWallData
* @property {number[]} c - Wall coordinates [x1, y1, x2, y2]
* @property {number} door - Door type (0=none, 1=door, 2=secret)
* @property {number} ds - Door state (0=closed, 1=open, 2=locked)
* @property {number} dir - Wall direction for one-way walls
* @property {number} move - Movement restriction type
* @property {number} sound - Sound restriction type
* @property {number} sight - Vision restriction type
* @property {number} light - Light restriction type
* @property {Object} flags - Custom module flags
*/
/**
* @typedef {Object} NormalizedLightData
* @property {number} x - X coordinate
* @property {number} y - Y coordinate
* @property {number} rotation - Light rotation angle
* @property {boolean} hidden - Whether light is hidden from players
* @property {boolean} walls - Whether light is blocked by walls
* @property {boolean} vision - Whether light provides vision
* @property {Object} config - Light configuration object
*/
/**
* @typedef {Object} NormalizedSceneData
* @property {string} [name] - Scene name
* @property {number} [width] - Scene width in pixels
* @property {number} [height] - Scene height in pixels
* @property {NormalizedGridSettings} grid - Grid configuration
* @property {number} padding - Scene padding multiplier
* @property {string} backgroundColor - Background color (hex)
* @property {boolean} globalLight - Whether global illumination is enabled
* @property {number} darkness - Darkness level (0-1)
* @property {NormalizedWallData[]} walls - Wall documents
* @property {NormalizedLightData[]} lights - Ambient light documents
* @property {Array} tokens - Token documents
* @property {Array} notes - Note documents
* @property {Array} drawings - Drawing documents
*/
/** Default values for grid configuration */
const GRID_DEFAULTS = {
SIZE: 100,
TYPE: 1, // Square grid
DISTANCE: 5,
UNITS: 'ft',
ALPHA: 0.2,
COLOR: '#000000'
};
/** Default scene settings */
const SCENE_DEFAULTS = {
PADDING: 0,
BACKGROUND_COLOR: '#000000',
DARKNESS: 0
};
/**
* Service class that normalizes imported scene data to Foundry's expected format.
* Handles various JSON export formats and provides sensible defaults.
*/
export class SceneDataNormalizer {
/**
* Transform imported scene configuration into Foundry's internal document format.
* Handles multiple input formats and normalizes all values.
*
* @param {Object|null|undefined} inputData - Raw imported scene data (may be null/undefined)
* @returns {NormalizedSceneData} Normalized scene configuration ready for Foundry
*
* @example
* const normalizer = new SceneDataNormalizer();
* const normalized = normalizer.normalizeToFoundryFormat(importedJson);
* // normalized.grid, normalized.walls, etc. are ready for Scene.create()
*/
normalizeToFoundryFormat(inputData) {
const sourceData = inputData || {};
const normalizedData = {
name: sourceData.name,
width: this.parseNumberOrUndefined(sourceData.width),
height: this.parseNumberOrUndefined(sourceData.height),
grid: this.normalizeGridSettings(sourceData),
padding: this.parseNumberWithDefault(sourceData.padding, SCENE_DEFAULTS.PADDING),
backgroundColor: sourceData.backgroundColor ?? sourceData.gridColor ?? SCENE_DEFAULTS.BACKGROUND_COLOR,
globalLight: !!sourceData.globalLight,
darkness: this.parseNumberWithDefault(sourceData.darkness, SCENE_DEFAULTS.DARKNESS),
walls: this.normalizeWallsData(sourceData.walls || []),
lights: this.normalizeLightsData(sourceData.lights || [], sourceData),
tokens: sourceData.tokens ?? [],
notes: sourceData.notes ?? [],
drawings: sourceData.drawings ?? []
};
return normalizedData;
}
/**
* Normalize grid settings from various input formats.
* Supports both flat properties and nested grid object.
*
* @param {Object} sourceData - Source data containing grid information
* @returns {NormalizedGridSettings} Normalized grid configuration
*/
normalizeGridSettings(sourceData) {
// Extract grid values from either flat properties or nested grid object
const gridSize = this.extractGridValue(sourceData, 'size', 'grid', GRID_DEFAULTS.SIZE);
const gridType = this.extractGridValue(sourceData, 'gridType', 'type', GRID_DEFAULTS.TYPE);
const gridDistance = sourceData.gridDistance ?? sourceData.grid?.distance ?? GRID_DEFAULTS.DISTANCE;
const gridUnits = sourceData.gridUnits ?? sourceData.grid?.units ?? GRID_DEFAULTS.UNITS;
const gridAlpha = this.parseNumberWithDefault(
sourceData.gridAlpha ?? sourceData.grid?.alpha,
GRID_DEFAULTS.ALPHA
);
const gridColor = sourceData.gridColor ?? sourceData.grid?.color ?? GRID_DEFAULTS.COLOR;
const offsetX = this.parseNumberWithDefault(sourceData.shiftX ?? sourceData.grid?.shiftX, 0);
const offsetY = this.parseNumberWithDefault(sourceData.shiftY ?? sourceData.grid?.shiftY, 0);
return {
size: gridSize,
type: gridType,
distance: gridDistance,
units: gridUnits,
alpha: gridAlpha,
color: gridColor,
offset: {
x: offsetX,
y: offsetY
}
};
}
/**
* Extract a grid value from source data, handling both number and object formats.
*
* @param {Object} sourceData - Source data object
* @param {string} flatKey - Key for flat property (e.g., 'gridType')
* @param {string} nestedKey - Key within grid object (e.g., 'type')
* @param {number} defaultValue - Default value if not found
* @returns {number} The extracted grid value
*/
extractGridValue(sourceData, flatKey, nestedKey, defaultValue) {
// Handle the special case where grid can be a number (size) or an object
if (nestedKey === 'grid' || flatKey === 'size') {
const rawGridValue = typeof sourceData.grid === 'number'
? sourceData.grid
: sourceData.grid?.size;
return this.parseNumberWithDefault(rawGridValue, defaultValue);
}
const flatValue = sourceData[flatKey];
const nestedValue = sourceData.grid?.[nestedKey];
return this.parseNumberWithDefault(
flatValue !== undefined ? flatValue : nestedValue,
defaultValue
);
}
/**
* Normalize an array of wall data to Foundry's Wall document format.
* Filters out regular walls that share exact coordinates with doors to prevent
* duplicate walls blocking door functionality.
*
* @param {Array} wallsArray - Array of raw wall data objects
* @returns {NormalizedWallData[]} Array of normalized wall documents
*/
normalizeWallsData(wallsArray) {
const normalizedWalls = wallsArray.map(wall => this.normalizeWall(wall));
return this.filterDuplicateWallsAtDoorLocations(normalizedWalls);
}
/**
* Filter out regular walls that have the same coordinates as doors.
* Some map exports include both a wall segment and a door at the same location,
* which causes the door to be blocked by the overlapping wall.
*
* @param {NormalizedWallData[]} walls - Array of normalized wall data
* @returns {NormalizedWallData[]} Filtered array with duplicate walls removed
*/
filterDuplicateWallsAtDoorLocations(walls) {
// Collect coordinates of all doors
const doorCoordinates = new Set();
for (const wall of walls) {
if (wall.door > 0 && Array.isArray(wall.c) && wall.c.length >= 4) {
// Store both forward and reverse coordinate strings to handle different orderings
const coordKey = wall.c.slice(0, 4).join(',');
const reverseKey = [wall.c[2], wall.c[3], wall.c[0], wall.c[1]].join(',');
doorCoordinates.add(coordKey);
doorCoordinates.add(reverseKey);
}
}
// If no doors, return walls as-is
if (doorCoordinates.size === 0) {
return walls;
}
// Filter out regular walls (door === 0) that share coordinates with doors
return walls.filter(wall => {
// Keep all doors
if (wall.door > 0) {
return true;
}
// Check if this regular wall overlaps with a door location
if (Array.isArray(wall.c) && wall.c.length >= 4) {
const coordKey = wall.c.slice(0, 4).join(',');
if (doorCoordinates.has(coordKey)) {
return false; // Remove this wall - there's a door here
}
}
return true;
});
}
/**
* Normalize a single wall object to Foundry's expected format.
*
* @param {Object} wall - Raw wall data
* @returns {NormalizedWallData} Normalized wall document
*/
normalizeWall(wall) {
const restrictionTypes = this.getWallRestrictionTypes();
const doorType = this.ensureFiniteNumber(wall.door, 0);
// Doors should default to NORMAL restrictions (blocking) when closed,
// while regular walls default to NONE (the source data usually specifies restrictions)
const isDoor = doorType > 0;
const defaultRestriction = isDoor ? restrictionTypes.NORMAL : restrictionTypes.NONE;
return {
c: this.normalizeWallCoordinates(wall.c),
door: doorType,
ds: this.ensureFiniteNumber(wall.ds, 0),
dir: this.ensureFiniteNumber(wall.dir, 0),
move: this.parseRestrictionValue(wall.move, defaultRestriction, restrictionTypes),
sound: this.parseRestrictionValue(wall.sound, defaultRestriction, restrictionTypes),
sight: this.parseRestrictionValue(wall.sense ?? wall.sight, defaultRestriction, restrictionTypes),
light: this.parseRestrictionValue(wall.light, defaultRestriction, restrictionTypes),
flags: wall.flags ?? {}
};
}
/**
* Normalize wall coordinates to ensure they are numbers.
*
* @param {Array} coordinates - Raw coordinate array
* @returns {number[]} Array of numeric coordinates [x1, y1, x2, y2]
*/
normalizeWallCoordinates(coordinates) {
if (!Array.isArray(coordinates)) {
return coordinates;
}
return coordinates.slice(0, 4).map(coord => Number(coord));
}
/**
* Get wall restriction type constants from Foundry or use defaults.
*
* @returns {{NONE: number, LIMITED: number, NORMAL: number}} Restriction type values
*/
getWallRestrictionTypes() {
return globalThis?.CONST?.WALL_RESTRICTION_TYPES || {
NONE: 0,
LIMITED: 10,
NORMAL: 20
};
}
/**
* Parse a wall restriction value from various input formats.
* Handles numbers, strings, and boolean values.
*
* @param {*} value - The value to parse
* @param {number} defaultValue - Default if parsing fails
* @param {Object} restrictionTypes - Available restriction type constants
* @returns {number} The restriction type value
*/
parseRestrictionValue(value, defaultValue, restrictionTypes) {
const validValues = new Set(Object.values(restrictionTypes));
// Already a valid restriction number
if (typeof value === 'number' && validValues.has(value)) {
return value;
}
// Falsy values map to NONE
if (value === 0 || value === '0' || value === false || value == null) {
return restrictionTypes.NONE;
}
// Truthy numeric values map to NORMAL
if (value === 1 || value === '1' || value === true) {
return restrictionTypes.NORMAL;
}
// Parse string values
if (typeof value === 'string') {
const lowercaseValue = value.toLowerCase();
if (lowercaseValue.startsWith('none')) {
return restrictionTypes.NONE;
}
if (lowercaseValue.startsWith('limit')) {
return restrictionTypes.LIMITED;
}
if (lowercaseValue.startsWith('norm')) {
return restrictionTypes.NORMAL;
}
}
return defaultValue;
}
/**
* Normalize an array of light data to Foundry's AmbientLight document format.
*
* @param {Array} lightsArray - Array of raw light data objects
* @param {Object} sourceData - Source scene data for context (grid settings, etc.)
* @returns {NormalizedLightData[]} Array of normalized light documents
*/
normalizeLightsData(lightsArray, sourceData = {}) {
// Calculate the conversion factor from source units to grid units
// Dungeon Alchemist exports light radii in map units (e.g., feet)
// Foundry expects radii in grid units (number of grid squares)
const gridDistance = sourceData.gridDistance ?? sourceData.grid?.distance ?? GRID_DEFAULTS.DISTANCE;
return lightsArray.map(light => this.normalizeLight(light, gridDistance));
}
/**
* Normalize a single light object to Foundry's expected format.
*
* @param {Object} light - Raw light data
* @param {number} gridDistance - Distance per grid cell for unit conversion
* @returns {NormalizedLightData} Normalized light document
*/
normalizeLight(light, gridDistance = GRID_DEFAULTS.DISTANCE) {
// Convert light radii from map units (feet) to grid units
// e.g., 33.75 feet / 5 feet per grid = 6.75 grid units
const brightRadius = this.convertToGridUnits(light.bright, gridDistance);
const dimRadius = this.convertToGridUnits(light.dim, gridDistance);
return {
x: Number(light.x),
y: Number(light.y),
rotation: 0,
hidden: false,
walls: true,
vision: false,
config: {
alpha: Number(light.tintAlpha ?? 0.5),
color: light.tintColor ?? null,
bright: brightRadius,
dim: dimRadius,
angle: 360
}
};
}
/**
* Convert a distance value from map units to grid units.
*
* @param {number|undefined} value - The value in map units (e.g., feet)
* @param {number} gridDistance - Distance per grid cell
* @returns {number} The value in grid units
*/
convertToGridUnits(value, gridDistance) {
const numValue = Number(value);
if (!Number.isFinite(numValue) || numValue <= 0) {
return 0;
}
// Avoid division by zero
if (!Number.isFinite(gridDistance) || gridDistance <= 0) {
return numValue;
}
return numValue / gridDistance;
}
/**
* Parse a value as a number, returning undefined if invalid.
*
* @param {*} value - Value to parse
* @returns {number|undefined} Parsed number or undefined
*/
parseNumberOrUndefined(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
/**
* Parse a value as a number, returning a default if invalid.
*
* @param {*} value - Value to parse
* @param {number} defaultValue - Default value if parsing fails
* @returns {number} Parsed number or default
*/
parseNumberWithDefault(value, defaultValue) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : defaultValue;
}
/**
* Ensure a value is a finite number, returning default if not.
*
* @param {*} value - Value to check
* @param {number} defaultValue - Default value
* @returns {number} The value if finite, otherwise default
*/
ensureFiniteNumber(value, defaultValue) {
const numValue = Number(value);
return Number.isFinite(numValue) ? numValue : defaultValue;
}
}
+946
View File
@@ -0,0 +1,946 @@
/**
* Scene Import Controller
*
* Main orchestration class that coordinates the battlemap import workflow.
* Handles file drops, manages state, and delegates to specialized services
* for grid detection, data normalization, storage, and UI updates.
*
* @module SceneImportController
*/
import { ImportPanelView } from './import-panel-view.js';
import { SceneDataNormalizer } from './scene-data-normalizer.js';
import { MediaStorageService } from './media-storage-service.js';
import { GridDetectionService } from './grid-detection-service.js';
import { FileProcessor } from './file-processor.js';
import { SceneBuilder } from './scene-builder.js';
/**
* @typedef {Object} BackgroundMediaData
* @property {string} data - Base64 data URL or blob URL for the media
* @property {string} filename - Original filename of the media
* @property {File} file - The original File object
* @property {boolean} isVideo - Whether the media is a video file
*/
/**
* @typedef {Object} FloorData
* @property {string} id - Unique identifier for the floor
* @property {BackgroundMediaData|null} mediaData - Background media for this floor
* @property {File|null} mediaFile - Original media file
* @property {Object|null} jsonData - Parsed JSON configuration
* @property {File|null} jsonFile - Original JSON file
*/
/** Module identifier for console logging */
const MODULE_LOG_PREFIX = 'Quick Battlemap Importer';
/** LocalStorage key for persisting no-grid preference */
const NO_GRID_STORAGE_KEY = 'quick-battlemap:no-grid';
/**
* Check if the Levels module by theripper93 is installed and active.
* @returns {boolean} True if Levels module is active
*/
function isLevelsModuleActive() {
return game.modules.get('levels')?.active ?? false;
}
/**
* Controller class that manages the battlemap import process.
* Coordinates between the UI panel, storage service, and data processing.
*/
export class SceneImportController {
constructor() {
/** @type {BackgroundMediaData|null} Currently loaded background media (legacy single-floor mode) */
this.backgroundMediaData = null;
/** @type {Object|null} Parsed scene configuration from JSON (legacy single-floor mode) */
this.importedSceneStructure = null;
/** @type {boolean} Enable verbose console logging for debugging */
this.isDebugLoggingEnabled = true;
/** @type {number} Counter for tracking concurrent async operations */
this.pendingOperationCount = 0;
/** @type {boolean} User preference to skip grid detection/application */
this.isNoGridModeEnabled = false;
/** @type {FloorData[]} Array of floor data for multi-floor scenes */
this.floors = [];
/** @type {File[]} Unmatched media files awaiting assignment */
this.unmatchedMediaFiles = [];
/** @type {File[]} Unmatched JSON files awaiting assignment */
this.unmatchedJsonFiles = [];
/** @type {number} Counter for generating unique floor IDs */
this.floorIdCounter = 0;
// Initialize services
this.panelView = new ImportPanelView();
this.dataNormalizer = new SceneDataNormalizer();
this.storageService = new MediaStorageService();
this.gridDetectionService = new GridDetectionService();
this.fileProcessor = new FileProcessor();
this.sceneBuilder = new SceneBuilder(this.isDebugLoggingEnabled);
}
/**
* Initialize the controller by setting up event handlers and loading preferences.
*/
initialize() {
this.registerCanvasDropHandler();
this.setupPanelEventCallbacks();
}
/**
* Show the import panel to the user. Validates GM permissions before displaying.
*/
showImportPanel() {
if (!game.user?.isGM) {
ui.notifications?.warn?.(game.i18n.localize('QUICKBATTLEMAP.GMOnly') ?? 'GM only');
return;
}
this.panelView.ensureCreated();
this.panelView.show();
}
/**
* Hide the import panel from view.
*/
hideImportPanel() {
this.panelView.hide();
}
/**
* Register a drop event handler on the Foundry canvas.
*/
registerCanvasDropHandler() {
canvas.stage?.on?.('drop', (event) => {
this.processDroppedFiles(event.data.originalEvent);
});
}
/**
* Wire up all callback functions for the panel view.
*/
setupPanelEventCallbacks() {
this.isNoGridModeEnabled = this.loadNoGridPreference();
this.panelView.setNoGridCheckboxState(this.isNoGridModeEnabled);
this.panelView.onCreateSceneRequested = () => this.executeSceneCreation();
this.panelView.onResetRequested = () => this.resetImportState();
this.panelView.onCloseRequested = () => this.hideImportPanel();
this.panelView.onFilesDropped = (event) => this.processDroppedFiles(event);
this.panelView.onNoGridPreferenceChanged = (isEnabled) => this.handleNoGridPreferenceChange(isEnabled);
this.panelView.onFloorOrderChanged = (action, floorId, targetId) => this.handleFloorOrderChange(action, floorId, targetId);
this.panelView.onFloorRemoved = (floorId) => this.handleFloorRemoval(floorId);
this.panelView.onFileMatchRequested = (fileName, fileType) => this.handleFileMatchRequest(fileName, fileType);
}
/**
* Load the no-grid preference from browser localStorage.
* @returns {boolean} True if no-grid mode was previously enabled
*/
loadNoGridPreference() {
try {
return localStorage.getItem(NO_GRID_STORAGE_KEY) === 'true';
} catch (_error) {
return false;
}
}
/**
* Handle changes to the no-grid preference checkbox.
* @param {boolean} isEnabled - Whether no-grid mode is now enabled
*/
handleNoGridPreferenceChange(isEnabled) {
this.isNoGridModeEnabled = !!isEnabled;
try {
localStorage.setItem(NO_GRID_STORAGE_KEY, String(this.isNoGridModeEnabled));
} catch (_error) { /* localStorage may not be available */ }
const wallStatusElement = document.querySelector('.wall-data-status .status-value');
if (wallStatusElement && this.isNoGridModeEnabled && wallStatusElement.title === 'Auto-detected grid') {
wallStatusElement.textContent = '❌';
wallStatusElement.title = '';
}
}
/**
* Process files dropped onto the panel or canvas.
* @param {DragEvent} dropEvent - The native drag-and-drop event
*/
processDroppedFiles(dropEvent) {
const droppedFiles = dropEvent.dataTransfer?.files;
if (!droppedFiles || droppedFiles.length === 0) return;
// Collect all files by type
const mediaFiles = [];
const jsonFiles = [];
for (let i = 0; i < droppedFiles.length; i++) {
const file = droppedFiles[i];
const fileType = this.fileProcessor.getFileType(file);
if (fileType === 'image' || fileType === 'video') {
mediaFiles.push({ file, type: fileType });
} else if (fileType === 'json') {
jsonFiles.push(file);
}
}
// If multiple files dropped AND Levels module is active, use multi-floor matching
const canUseMultiFloor = isLevelsModuleActive();
if (canUseMultiFloor && (mediaFiles.length > 1 || (mediaFiles.length >= 1 && jsonFiles.length >= 1))) {
this.processMultipleFiles(mediaFiles, jsonFiles);
} else {
// Single file mode - legacy behavior
for (const { file, type } of mediaFiles) {
if (type === 'image') {
this.handleImageFile(file);
} else if (type === 'video') {
this.handleVideoFile(file);
}
}
for (const file of jsonFiles) {
this.handleJsonConfigFile(file);
}
}
}
/**
* Process multiple files and attempt to match them by name.
* @param {Array<{file: File, type: string}>} mediaFiles - Array of media files
* @param {File[]} jsonFiles - Array of JSON files
*/
async processMultipleFiles(mediaFiles, jsonFiles) {
// Extract base names for matching
const getBaseName = (filename) => {
const name = filename.toLowerCase();
// Remove common suffixes and extensions
return name
.replace(/\.(png|jpg|jpeg|gif|webp|webm|mp4|json)$/i, '')
.replace(/[-_]?(walls|grid|config|data|export)$/i, '')
.trim();
};
const matchedFloors = [];
const unmatchedMedia = [];
const unmatchedJson = [...jsonFiles];
// Try to match each media file with a JSON file
for (const mediaItem of mediaFiles) {
const mediaBaseName = getBaseName(mediaItem.file.name);
let matchedJsonIndex = -1;
// Find matching JSON file
for (let i = 0; i < unmatchedJson.length; i++) {
const jsonBaseName = getBaseName(unmatchedJson[i].name);
if (mediaBaseName === jsonBaseName ||
mediaBaseName.includes(jsonBaseName) ||
jsonBaseName.includes(mediaBaseName)) {
matchedJsonIndex = i;
break;
}
}
if (matchedJsonIndex >= 0) {
// Found a match
const jsonFile = unmatchedJson.splice(matchedJsonIndex, 1)[0];
matchedFloors.push({
mediaItem,
jsonFile
});
} else {
// No match found, create floor without JSON
unmatchedMedia.push(mediaItem);
}
}
// Create floors for matched pairs
for (const { mediaItem, jsonFile } of matchedFloors) {
await this.createFloorFromFiles(mediaItem.file, mediaItem.type, jsonFile);
}
// Create floors for unmatched media (without JSON)
for (const mediaItem of unmatchedMedia) {
await this.createFloorFromFiles(mediaItem.file, mediaItem.type, null);
}
// Store unmatched JSON files for manual assignment
this.unmatchedJsonFiles = unmatchedJson;
// Update UI
this.refreshFloorListUI();
this.updateCreateButtonState();
}
/**
* Create a new floor from media and optional JSON files.
* @param {File} mediaFile - The media file
* @param {string} mediaType - 'image' or 'video'
* @param {File|null} jsonFile - Optional JSON file
* @returns {Promise<FloorData>} The created floor data
*/
async createFloorFromFiles(mediaFile, mediaType, jsonFile) {
const floorId = `floor-${++this.floorIdCounter}`;
// Process media file
let mediaData;
if (mediaType === 'image') {
const processedImage = await this.fileProcessor.processImageFile(mediaFile);
mediaData = {
data: processedImage.dataUrl,
filename: processedImage.filename,
file: processedImage.file,
isVideo: false
};
} else {
const processedVideo = this.fileProcessor.processVideoFile(mediaFile);
mediaData = {
data: processedVideo.blobUrl,
filename: processedVideo.filename,
file: processedVideo.file,
isVideo: true
};
}
// Process JSON file if provided
let jsonData = null;
if (jsonFile) {
try {
const processedJson = await this.fileProcessor.processJsonFile(jsonFile);
jsonData = processedJson.parsedContent;
} catch (error) {
console.warn(`${MODULE_LOG_PREFIX} | Failed to parse JSON for floor:`, error);
}
}
const floor = {
id: floorId,
mediaData,
mediaFile,
jsonData,
jsonFile
};
this.floors.push(floor);
// Run grid detection for image floors without JSON
if (!jsonData && !mediaData.isVideo && !this.isNoGridModeEnabled) {
this.runGridAutoDetectionForFloor(floor);
}
return floor;
}
/**
* Run grid auto-detection for a specific floor.
* @param {FloorData} floor - The floor to detect grid for
*/
async runGridAutoDetectionForFloor(floor) {
try {
const result = await this.gridDetectionService.detectGridFromImage(floor.mediaFile);
if (result && Number.isFinite(result.gridSize) && result.gridSize > 0) {
floor.jsonData = {
grid: {
size: Math.round(result.gridSize),
type: 1, distance: 5, units: 'ft', alpha: 0.2, color: '#000000'
},
shiftX: Math.round(result.xOffset || 0),
shiftY: Math.round(result.yOffset || 0),
walls: [], lights: []
};
floor.autoDetectedGrid = true;
this.refreshFloorListUI();
}
} catch (error) {
if (this.isDebugLoggingEnabled) {
console.warn(`${MODULE_LOG_PREFIX} | Auto grid detection failed for floor:`, error);
}
}
}
/**
* Refresh the floor list UI.
*/
refreshFloorListUI() {
const floorDisplayData = this.floors.map(floor => ({
id: floor.id,
name: floor.mediaData?.filename || 'Unknown',
mediaFile: floor.mediaFile,
jsonFile: floor.jsonFile,
hasAutoGrid: floor.autoDetectedGrid
}));
this.panelView.renderFloorList(floorDisplayData);
this.panelView.showUnmatchedFiles(
this.unmatchedMediaFiles.map(m => m.file || m),
this.unmatchedJsonFiles
);
// Update status indicators for multi-floor mode
const hasFloors = this.floors.length > 0;
const allHaveJson = this.floors.every(f => f.jsonData);
this.panelView.updateBackgroundMediaStatus(hasFloors,
hasFloors ? `${this.floors.length} floor(s)` : '');
this.panelView.updateWallDataStatus(allHaveJson,
allHaveJson ? 'All floors have data' : 'Some floors missing data');
}
/**
* Handle floor order change request.
* @param {string} action - 'up', 'down', or 'reorder'
* @param {string} floorId - ID of the floor to move
* @param {string} [targetId] - Target floor ID for reorder
*/
handleFloorOrderChange(action, floorId, targetId) {
const currentIndex = this.floors.findIndex(f => f.id === floorId);
if (currentIndex === -1) return;
if (action === 'up' && currentIndex > 0) {
[this.floors[currentIndex - 1], this.floors[currentIndex]] =
[this.floors[currentIndex], this.floors[currentIndex - 1]];
} else if (action === 'down' && currentIndex < this.floors.length - 1) {
[this.floors[currentIndex], this.floors[currentIndex + 1]] =
[this.floors[currentIndex + 1], this.floors[currentIndex]];
} else if (action === 'reorder' && targetId) {
const targetIndex = this.floors.findIndex(f => f.id === targetId);
if (targetIndex !== -1 && targetIndex !== currentIndex) {
const [movedFloor] = this.floors.splice(currentIndex, 1);
this.floors.splice(targetIndex, 0, movedFloor);
}
}
this.refreshFloorListUI();
}
/**
* Handle floor removal request.
* @param {string} floorId - ID of the floor to remove
*/
handleFloorRemoval(floorId) {
const index = this.floors.findIndex(f => f.id === floorId);
if (index === -1) return;
const floor = this.floors[index];
// Revoke blob URL if video
if (floor.mediaData?.isVideo) {
this.fileProcessor.revokeBlobUrl(floor.mediaData.data);
}
this.floors.splice(index, 1);
this.refreshFloorListUI();
this.updateCreateButtonState();
}
/**
* Handle file match request from UI.
* @param {string} fileName - Name of the file to match
* @param {string} fileType - 'media' or 'json'
*/
async handleFileMatchRequest(fileName, fileType) {
if (this.floors.length === 0 && fileType === 'json') {
ui.notifications.warn(game.i18n.localize('QUICKBATTLEMAP.NoFloorsToMatch'));
return;
}
const floorId = await this.panelView.promptFloorSelection(
this.floors.map(f => ({
id: f.id,
mediaFile: f.mediaFile
})),
fileName,
fileType
);
if (!floorId) return;
if (fileType === 'json') {
// Find and assign the JSON file
const jsonIndex = this.unmatchedJsonFiles.findIndex(f => f.name === fileName);
if (jsonIndex === -1) return;
const jsonFile = this.unmatchedJsonFiles[jsonIndex];
const floor = this.floors.find(f => f.id === floorId);
if (floor) {
try {
const processedJson = await this.fileProcessor.processJsonFile(jsonFile);
floor.jsonData = processedJson.parsedContent;
floor.jsonFile = jsonFile;
this.unmatchedJsonFiles.splice(jsonIndex, 1);
this.refreshFloorListUI();
} catch (error) {
ui.notifications.error(game.i18n.localize('QUICKBATTLEMAP.InvalidJSON'));
}
}
} else if (fileType === 'media') {
// Find and assign/create from media file
const mediaIndex = this.unmatchedMediaFiles.findIndex(m => (m.file || m).name === fileName);
if (mediaIndex === -1) return;
const mediaItem = this.unmatchedMediaFiles[mediaIndex];
if (floorId === '__new__') {
// Create a new floor
const file = mediaItem.file || mediaItem;
const type = this.fileProcessor.getFileType(file);
await this.createFloorFromFiles(file, type === 'video' ? 'video' : 'image', null);
}
this.unmatchedMediaFiles.splice(mediaIndex, 1);
this.refreshFloorListUI();
}
this.updateCreateButtonState();
}
/**
* Process an image file for use as scene background.
* @param {File} imageFile - The dropped image file
*/
async handleImageFile(imageFile) {
try {
const processedImage = await this.fileProcessor.processImageFile(imageFile);
this.backgroundMediaData = {
data: processedImage.dataUrl,
filename: processedImage.filename,
file: processedImage.file,
isVideo: false
};
this.panelView.updateBackgroundMediaStatus(true, imageFile.name);
this.updateCreateButtonState();
if (!this.isNoGridModeEnabled) {
await this.runGridAutoDetection(imageFile);
}
} catch (error) {
console.error(`${MODULE_LOG_PREFIX} | Image processing failed:`, error);
}
}
/**
* Run automatic grid detection on an image file.
* @param {File} imageFile - The image file to analyze
*/
async runGridAutoDetection(imageFile) {
this.showProgressIndicator(game.i18n.localize('QUICKBATTLEMAP.ProgressAnalyzing'));
try {
await this.detectAndApplyGridFromImage(imageFile);
} catch (error) {
if (this.isDebugLoggingEnabled) {
console.warn(`${MODULE_LOG_PREFIX} | Auto grid detection failed:`, error);
}
} finally {
this.hideProgressIndicator();
}
}
/**
* Process a video file for use as scene background.
* @param {File} videoFile - The dropped video file
*/
handleVideoFile(videoFile) {
const processedVideo = this.fileProcessor.processVideoFile(videoFile);
this.backgroundMediaData = {
data: processedVideo.blobUrl,
filename: processedVideo.filename,
file: processedVideo.file,
isVideo: true
};
this.panelView.updateBackgroundMediaStatus(true, videoFile.name);
this.updateCreateButtonState();
}
/**
* Process a JSON configuration file.
* @param {File} jsonFile - The dropped JSON file
*/
async handleJsonConfigFile(jsonFile) {
try {
const processedJson = await this.fileProcessor.processJsonFile(jsonFile);
this.importedSceneStructure = processedJson.parsedContent;
this.panelView.updateWallDataStatus(true, jsonFile.name);
this.updateCreateButtonState();
} catch (error) {
console.error(`${MODULE_LOG_PREFIX} | JSON parse error:`, error);
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.InvalidJSON"));
}
}
/**
* Update the enabled state of the "Create Scene" button.
*/
updateCreateButtonState() {
const createButton = document.querySelector('.create-scene-button');
if (createButton) {
// Enable if we have at least one floor OR legacy single background
const hasContent = this.floors.length > 0 || this.backgroundMediaData;
createButton.disabled = !hasContent;
}
}
/**
* Main scene creation workflow.
*/
async executeSceneCreation() {
// Check if we're in multi-floor mode
if (this.floors.length > 0) {
await this.executeMultiFloorSceneCreation();
return;
}
// Legacy single-floor creation
if (!this.backgroundMediaData) {
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.MissingFiles"));
return;
}
await this.ensureGridDataExists();
this.warnIfGridDataMissing();
try {
ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.CreatingScene"));
const uploadResult = await this.uploadBackgroundMedia();
if (!uploadResult?.path) {
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.UploadFailed"));
return;
}
const mediaDimensions = await this.fileProcessor.getMediaDimensions(this.backgroundMediaData);
const normalizedData = this.dataNormalizer.normalizeToFoundryFormat(this.importedSceneStructure);
this.logNormalizedData(normalizedData);
const sceneName = this.determineSceneName(normalizedData.name);
const createdScene = await this.sceneBuilder.createScene({
backgroundPath: uploadResult.path,
sceneName: sceneName,
width: normalizedData.width || mediaDimensions.width || 1920,
height: normalizedData.height || mediaDimensions.height || 1080,
padding: normalizedData.padding,
backgroundColor: normalizedData.backgroundColor,
globalLight: normalizedData.globalLight,
darkness: normalizedData.darkness
});
await this.sceneBuilder.activateAndWaitForCanvas(createdScene);
await this.sceneBuilder.applyGridSettings(createdScene, normalizedData.grid, this.isNoGridModeEnabled);
await this.sceneBuilder.createWalls(createdScene, normalizedData.walls);
await this.sceneBuilder.createLights(createdScene, normalizedData.lights);
this.cleanupAfterCreation(sceneName);
} catch (error) {
console.error(`${MODULE_LOG_PREFIX} | Scene creation failed:`, error);
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.SceneCreationFailed"));
}
}
/**
* Execute multi-floor scene creation.
* Creates a scene with multiple foreground tiles for each floor level using the Levels module.
*/
async executeMultiFloorSceneCreation() {
if (this.floors.length === 0) {
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.MissingFiles"));
return;
}
if (!isLevelsModuleActive()) {
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.LevelsModuleRequired"));
return;
}
try {
ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.CreatingMultiFloorScene"));
// Use the first floor as the base/background
const baseFloor = this.floors[0];
// Upload all floor media files
this.showProgressIndicator(game.i18n.localize('QUICKBATTLEMAP.ProgressUploading'));
const uploadedFloors = [];
for (let i = 0; i < this.floors.length; i++) {
const floor = this.floors[i];
const uploadResult = await this.storageService.uploadBackgroundMedia(floor.mediaData, game.world.id);
if (!uploadResult?.path) {
ui.notifications.error(`${game.i18n.localize("QUICKBATTLEMAP.UploadFailed")}: Floor ${i + 1}`);
this.hideProgressIndicator();
return;
}
uploadedFloors.push({
...floor,
uploadedPath: uploadResult.path
});
}
this.hideProgressIndicator();
// Get dimensions from the base floor
const baseDimensions = await this.fileProcessor.getMediaDimensions(baseFloor.mediaData);
const baseNormalizedData = this.dataNormalizer.normalizeToFoundryFormat(baseFloor.jsonData);
// Determine scene name from first floor
const sceneName = this.determineSceneName(baseNormalizedData.name, baseFloor.mediaData?.filename);
// Calculate floor elevations (each floor is 10 units apart by default)
const floorHeight = baseNormalizedData.grid?.distance || 5;
const floorElevations = uploadedFloors.map((_, i) => i * floorHeight * 2);
// Build sceneLevels array for Levels module: [bottom, top, name]
const sceneLevels = uploadedFloors.map((floor, i) => {
const bottom = floorElevations[i];
const top = (i < uploadedFloors.length - 1) ? floorElevations[i + 1] - 1 : bottom + floorHeight * 2 - 1;
const name = `Floor ${i + 1}`;
return [bottom, top, name];
});
// Create the scene with the base floor as background
const createdScene = await this.sceneBuilder.createScene({
backgroundPath: uploadedFloors[0].uploadedPath,
sceneName: sceneName,
width: baseNormalizedData.width || baseDimensions.width || 1920,
height: baseNormalizedData.height || baseDimensions.height || 1080,
padding: baseNormalizedData.padding,
backgroundColor: baseNormalizedData.backgroundColor,
globalLight: baseNormalizedData.globalLight,
darkness: baseNormalizedData.darkness
});
await this.sceneBuilder.activateAndWaitForCanvas(createdScene);
await this.sceneBuilder.applyGridSettings(createdScene, baseNormalizedData.grid, this.isNoGridModeEnabled);
// Create walls and lights from base floor with elevation
await this.sceneBuilder.createWallsWithElevation(createdScene, baseNormalizedData.walls, floorElevations[0], floorElevations[0] + floorHeight * 2 - 1);
await this.sceneBuilder.createLightsWithElevation(createdScene, baseNormalizedData.lights, floorElevations[0], floorElevations[0] + floorHeight * 2 - 1);
// Create floor tiles for additional floors (floors 2+) with proper Levels flags
if (uploadedFloors.length > 1) {
await this.sceneBuilder.createLevelsFloorTiles(
createdScene,
uploadedFloors.slice(1),
baseDimensions,
this.dataNormalizer,
floorElevations.slice(1),
floorHeight
);
}
// Set Levels module scene flags for floor definitions
// Only set sceneLevels - let backgroundElevation default to 0
// This prevents the background from incorrectly hiding/showing
await createdScene.update({
'flags.levels.sceneLevels': sceneLevels
});
this.cleanupAfterMultiFloorCreation(sceneName);
} catch (error) {
console.error(`${MODULE_LOG_PREFIX} | Multi-floor scene creation failed:`, error);
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.SceneCreationFailed"));
}
}
/**
* Ensure grid data exists before scene creation.
*/
async ensureGridDataExists() {
const shouldDetect = !this.isNoGridModeEnabled &&
!this.importedSceneStructure &&
this.backgroundMediaData?.file &&
!this.backgroundMediaData?.isVideo;
if (shouldDetect) {
try {
this.showProgressIndicator(game.i18n.localize('QUICKBATTLEMAP.ProgressAnalyzing'));
await this.detectAndApplyGridFromImage(this.backgroundMediaData.file);
} catch (_error) { /* handled below */ }
finally {
this.hideProgressIndicator();
}
}
}
/**
* Display a warning if grid data is missing.
*/
warnIfGridDataMissing() {
if (this.isNoGridModeEnabled || this.importedSceneStructure) return;
const message = this.backgroundMediaData?.isVideo
? "No grid data provided for video. Drop a JSON export or enable the No Grid option."
: "Grid data missing and auto-detection failed. Drop a JSON export or set grid manually.";
ui.notifications.error(message);
}
/**
* Upload the background media to Foundry's storage.
* @returns {Promise<{path: string}|null>} Upload result
*/
async uploadBackgroundMedia() {
this.showProgressIndicator(game.i18n.localize('QUICKBATTLEMAP.ProgressUploading'));
try {
return await this.storageService.uploadBackgroundMedia(this.backgroundMediaData, game.world.id);
} finally {
this.hideProgressIndicator();
}
}
/**
* Log normalized scene data for debugging.
* @param {Object} data - The normalized scene configuration
*/
logNormalizedData(data) {
if (!this.isDebugLoggingEnabled) return;
console.log(`${MODULE_LOG_PREFIX} | Normalized grid:`, data.grid);
console.log(`${MODULE_LOG_PREFIX} | First wall:`, data.walls?.[0]);
console.log(`${MODULE_LOG_PREFIX} | First light:`, data.lights?.[0]);
}
/**
* Determine the scene name from config or filename.
* @param {string|undefined} configuredName - Name from JSON config
* @param {string} [fallbackFilename] - Fallback filename if no config name
* @returns {string} The scene name to use
*/
determineSceneName(configuredName, fallbackFilename) {
if (configuredName) return configuredName;
const filename = fallbackFilename || this.backgroundMediaData?.filename;
const nameFromFile = filename?.split('.').slice(0, -1).join('.');
return nameFromFile || game.i18n.localize("QUICKBATTLEMAP.DefaultSceneName");
}
/**
* Clean up state after successful scene creation.
* @param {string} sceneName - Name of the created scene
*/
cleanupAfterCreation(sceneName) {
this.fileProcessor.revokeBlobUrl(this.backgroundMediaData?.data);
this.backgroundMediaData = null;
this.importedSceneStructure = null;
this.panelView.updateBackgroundMediaStatus(false, '');
this.panelView.updateWallDataStatus(false, '');
const createButton = document.querySelector('.create-scene-button');
if (createButton) createButton.disabled = true;
ui.notifications.info(`${game.i18n.localize("QUICKBATTLEMAP.SceneCreated")}: ${sceneName}`);
}
/**
* Clean up state after successful multi-floor scene creation.
* @param {string} sceneName - Name of the created scene
*/
cleanupAfterMultiFloorCreation(sceneName) {
// Revoke all blob URLs
for (const floor of this.floors) {
if (floor.mediaData?.isVideo) {
this.fileProcessor.revokeBlobUrl(floor.mediaData.data);
}
}
// Reset all state
this.floors = [];
this.unmatchedMediaFiles = [];
this.unmatchedJsonFiles = [];
this.backgroundMediaData = null;
this.importedSceneStructure = null;
// Update UI
this.panelView.clearFloorList();
this.panelView.updateBackgroundMediaStatus(false, '');
this.panelView.updateWallDataStatus(false, '');
const createButton = document.querySelector('.create-scene-button');
if (createButton) createButton.disabled = true;
ui.notifications.info(`${game.i18n.localize("QUICKBATTLEMAP.SceneCreated")}: ${sceneName}`);
}
/**
* Detect grid settings from an image file.
* @param {File} imageFile - The image file to analyze
*/
async detectAndApplyGridFromImage(imageFile) {
if (this.importedSceneStructure) return;
const result = await this.gridDetectionService.detectGridFromImage(imageFile);
if (!result || !Number.isFinite(result.gridSize) || result.gridSize <= 0) return;
this.importedSceneStructure = {
grid: {
size: Math.round(result.gridSize),
type: 1, distance: 5, units: 'ft', alpha: 0.2, color: '#000000'
},
shiftX: Math.round(result.xOffset || 0),
shiftY: Math.round(result.yOffset || 0),
walls: [], lights: []
};
this.panelView.updateWallDataStatus(true, 'Auto-detected grid');
this.updateCreateButtonState();
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Auto grid detection success:`, this.importedSceneStructure);
}
}
/**
* Show progress indicator with a status message.
* @param {string} message - Message to display
*/
showProgressIndicator(message) {
this.pendingOperationCount = Math.max(0, this.pendingOperationCount) + 1;
this.panelView.showBusyState(message);
}
/**
* Hide progress indicator when operation completes.
*/
hideProgressIndicator() {
this.pendingOperationCount = Math.max(0, this.pendingOperationCount - 1);
if (this.pendingOperationCount === 0) {
this.panelView.clearBusyState();
}
}
/**
* Reset all import state to initial values.
*/
resetImportState() {
// Revoke legacy blob URL
this.fileProcessor.revokeBlobUrl(this.backgroundMediaData?.data);
// Revoke all floor blob URLs
for (const floor of this.floors) {
if (floor.mediaData?.isVideo) {
this.fileProcessor.revokeBlobUrl(floor.mediaData.data);
}
}
// Reset all state
this.backgroundMediaData = null;
this.importedSceneStructure = null;
this.floors = [];
this.unmatchedMediaFiles = [];
this.unmatchedJsonFiles = [];
this.pendingOperationCount = 0;
this.isNoGridModeEnabled = this.loadNoGridPreference();
// Reset UI
this.panelView.resetAllStatuses(this.isNoGridModeEnabled);
this.panelView.clearFloorList();
}
}
+287
View File
@@ -0,0 +1,287 @@
/**
* Signal Processing Utilities
*
* Low-level signal processing functions for grid detection.
* Provides autocorrelation, filtering, and normalization algorithms
* used by the GridDetectionService.
*
* @module SignalProcessingUtils
*/
/**
* @typedef {Object} AutocorrelationEntry
* @property {number} lag - The lag value (distance between compared samples)
* @property {number} val - The autocorrelation coefficient at this lag
*/
/**
* @typedef {Object} PeriodCandidate
* @property {number} value - The detected period (lag) value
* @property {number} score - Confidence score for this period
*/
/**
* Compute normalized autocorrelation of a signal.
* Autocorrelation measures how similar a signal is to a delayed version of itself.
* Peaks in autocorrelation indicate periodic patterns.
*
* @param {Float32Array} signal - Input signal
* @param {number} minLag - Minimum lag to consider (avoids self-correlation peak)
* @param {number} maxLag - Maximum lag to consider
* @returns {AutocorrelationEntry[]} Array of autocorrelation values for each lag
*
* @example
* const signal = new Float32Array([1, 0, 1, 0, 1, 0]); // Periodic signal
* const autocorr = computeAutocorrelation(signal, 1, 3);
* // autocorr[1].val would be high (period of 2)
*/
export function computeAutocorrelation(signal, minLag, maxLag) {
const signalLength = signal.length;
// Calculate mean of signal
let sum = 0;
for (let i = 0; i < signalLength; i++) {
sum += signal[i];
}
const mean = sum / signalLength;
// Calculate denominator (variance-like term for normalization)
let denominator = 0;
for (let i = 0; i < signalLength; i++) {
const deviation = signal[i] - mean;
denominator += deviation * deviation;
}
denominator = denominator || 1; // Prevent division by zero
// Calculate autocorrelation for each lag value
const result = [];
for (let lag = minLag; lag <= maxLag; lag++) {
let numerator = 0;
for (let i = 0; i + lag < signalLength; i++) {
numerator += (signal[i] - mean) * (signal[i + lag] - mean);
}
result.push({
lag: lag,
val: numerator / denominator
});
}
return result;
}
/**
* Apply a high-pass filter by subtracting a moving average.
* This removes low-frequency trends and emphasizes periodic patterns (like grid lines).
*
* @param {Float32Array} signal - Input signal
* @param {number} windowSize - Size of the averaging window
* @returns {Float32Array} High-pass filtered signal
*
* @example
* const signal = new Float32Array([10, 11, 10, 11, 10]); // Signal with DC offset
* const filtered = applyHighPassFilter(signal, 3);
* // filtered values will oscillate around 0
*/
export function applyHighPassFilter(signal, windowSize) {
const length = signal.length;
const effectiveWindow = Math.max(3, windowSize | 0);
const halfWindow = (effectiveWindow / 2) | 0;
const output = new Float32Array(length);
let runningSum = 0;
// Initialize running sum with first window
const initialWindowSize = Math.min(length, effectiveWindow);
for (let i = 0; i < initialWindowSize; i++) {
runningSum += signal[i];
}
// Compute high-passed values using sliding window
for (let i = 0; i < length; i++) {
const leftBoundary = i - halfWindow - 1;
const rightBoundary = i + halfWindow;
// Update running sum with sliding window
if (rightBoundary < length && i + halfWindow < length) {
runningSum += signal[rightBoundary];
}
if (leftBoundary >= 0) {
runningSum -= signal[leftBoundary];
}
// Calculate local average
const spanStart = Math.max(0, i - halfWindow);
const spanEnd = Math.min(length - 1, i + halfWindow);
const spanSize = (spanEnd - spanStart + 1) || 1;
const localAverage = runningSum / spanSize;
// High-pass = original value minus local average
output[i] = signal[i] - localAverage;
}
// Suppress negative values (edge responses are positive peaks)
for (let i = 0; i < length; i++) {
if (output[i] < 0) {
output[i] *= 0.2;
}
}
return output;
}
/**
* Normalize a signal to the range [0, 1].
*
* @param {Float32Array} signal - Input signal
* @returns {Float32Array} Normalized signal with values between 0 and 1
*
* @example
* const signal = new Float32Array([10, 20, 30]);
* const normalized = normalizeSignal(signal);
* // normalized = [0, 0.5, 1]
*/
export function normalizeSignal(signal) {
let maxValue = -Infinity;
let minValue = Infinity;
// Find min and max values
for (let i = 0; i < signal.length; i++) {
if (signal[i] > maxValue) maxValue = signal[i];
if (signal[i] < minValue) minValue = signal[i];
}
const range = (maxValue - minValue) || 1; // Prevent division by zero
const normalized = new Float32Array(signal.length);
// Apply min-max normalization
for (let i = 0; i < signal.length; i++) {
normalized[i] = (signal[i] - minValue) / range;
}
return normalized;
}
/**
* Find the best period from autocorrelation data.
* Looks for the first significant peak in the autocorrelation.
*
* @param {AutocorrelationEntry[]} autocorrelation - Autocorrelation data
* @returns {PeriodCandidate|null} Best period candidate or null if none found
*/
export function findBestPeriodFromAutocorrelation(autocorrelation) {
if (!autocorrelation || !autocorrelation.length) {
return null;
}
// Find all local peaks (values higher than both neighbors)
const peaks = [];
for (let i = 1; i < autocorrelation.length - 1; i++) {
const isPeak = autocorrelation[i].val > autocorrelation[i - 1].val &&
autocorrelation[i].val >= autocorrelation[i + 1].val;
if (isPeak) {
peaks.push(autocorrelation[i]);
}
}
if (!peaks.length) {
return null;
}
// Sort by value (strongest peaks first)
peaks.sort((a, b) => b.val - a.val);
// Take top peaks and sort by lag (prefer smaller periods = fundamental frequency)
const topPeaks = peaks.slice(0, 5).sort((a, b) => a.lag - b.lag);
const bestPeak = topPeaks[0];
return {
value: bestPeak.lag,
score: bestPeak.val
};
}
/**
* Combine period candidates from X and Y axis analysis.
* Uses the more confident estimate, or averages if both agree.
*
* @param {PeriodCandidate|null} periodX - X-axis period candidate
* @param {PeriodCandidate|null} periodY - Y-axis period candidate
* @returns {number|null} Combined period estimate or null
*/
export function combinePeriodCandidates(periodX, periodY) {
if (periodX && periodY) {
// If both axes agree (within 2 pixels), average them for better accuracy
if (Math.abs(periodX.value - periodY.value) <= 2) {
return (periodX.value + periodY.value) / 2;
}
// Otherwise, use the one with higher confidence score
return periodX.score >= periodY.score ? periodX.value : periodY.value;
}
// Use whichever axis gave a result
if (periodX) return periodX.value;
if (periodY) return periodY.value;
return null;
}
/**
* Estimate the optimal grid offset from a projection signal.
* Finds the shift value that best aligns with periodic peaks.
*
* @param {Float32Array} signal - Normalized projection signal
* @param {number} period - Detected grid period
* @returns {number} Optimal offset value (0 to period-1)
*/
export function estimateGridOffset(signal, period) {
if (!period || period < 2) {
return 0;
}
const length = signal.length;
let bestOffset = 0;
let bestScore = -Infinity;
// Normalize signal for fair scoring
let maxValue = -Infinity;
for (const value of signal) {
if (value > maxValue) maxValue = value;
}
const normalizer = maxValue ? 1 / maxValue : 1;
// Try each possible offset and find the one with highest sum at periodic intervals
for (let offset = 0; offset < period; offset++) {
let sum = 0;
let count = 0;
// Sum signal values at periodic intervals starting from this offset
for (let i = offset; i < length; i += period) {
sum += signal[i] * normalizer;
count++;
}
const score = count ? sum / count : -Infinity;
if (score > bestScore) {
bestScore = score;
bestOffset = offset;
}
}
return bestOffset;
}
/**
* Clamp a numeric value to a specified range.
*
* @param {number} value - The value to clamp
* @param {number} min - Minimum allowed value
* @param {number} max - Maximum allowed value
* @returns {number} The clamped value
*/
export function clampValue(value, min, max) {
return value < min ? min : value > max ? max : value;
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Myxeliums Battlemap Importer - Main Entry Point
*
* This module provides a streamlined way to import battlemaps into Foundry VTT.
* It adds a "Quick import" button to the Scenes sidebar that opens a drag-and-drop
* panel for creating scenes from images/videos and optional JSON configuration files.
*
* @module MyxeliumsBattlemap
* @author Myxelium
* @license MIT
*/
import { SceneImportController } from './lib/scene-import-controller.js';
/** @type {SceneImportController|null} Singleton instance of the import controller */
let sceneImportController = null;
/**
* Module identifier used for logging and namespacing
* @constant {string}
*/
const MODULE_ID = 'Myxeliums Battlemap Importer';
/**
* CSS class name for the quick import button to prevent duplicate insertion
* @constant {string}
*/
const QUICK_IMPORT_BUTTON_CLASS = 'myxeliums-battlemap-quick-import';
/**
* Initialize the module when Foundry is ready.
* Sets up the import controller and registers necessary handlers.
*/
Hooks.once('init', async function () {
console.log(`${MODULE_ID} | Initializing module`);
});
/**
* Complete module setup after Foundry is fully loaded.
* Creates the controller instance and displays a ready notification.
*/
Hooks.once('ready', async function () {
console.log(`${MODULE_ID} | Module ready`);
sceneImportController = new SceneImportController();
sceneImportController.initialize();
ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.Ready"));
});
/**
* Add the "Quick import" button to the Scenes directory header.
* This hook fires whenever the SceneDirectory is rendered.
*
* @param {Application} _app - The SceneDirectory application instance (unused)
* @param {jQuery|HTMLElement} html - The rendered HTML element
*/
Hooks.on('renderSceneDirectory', (_app, html) => {
// Only GMs can use the quick import feature
if (!game.user?.isGM) {
return;
}
// Handle different HTML element formats across Foundry versions
const rootElement = html?.[0] || html?.element || html;
if (!(rootElement instanceof HTMLElement)) {
return;
}
// Prevent adding duplicate buttons
const existingButton = rootElement.querySelector(`button.${QUICK_IMPORT_BUTTON_CLASS}`);
if (existingButton) {
return;
}
// Find a suitable container for the button
const buttonContainer = findButtonContainer(rootElement);
if (!buttonContainer) {
return;
}
// Create and append the quick import button
const quickImportButton = createQuickImportButton();
buttonContainer.appendChild(quickImportButton);
});
/**
* Find a suitable container element for the quick import button.
* Tries multiple selectors for compatibility across Foundry versions.
*
* @param {HTMLElement} rootElement - The root element to search within
* @returns {HTMLElement|null} The container element or null if not found
*/
function findButtonContainer(rootElement) {
const containerSelectors = [
'.header-actions',
'.action-buttons',
'.directory-header'
];
for (const selector of containerSelectors) {
const container = rootElement.querySelector(selector);
if (container) {
return container;
}
}
return null;
}
/**
* Create the quick import button element with icon and click handler.
*
* @returns {HTMLButtonElement} The configured button element
*/
function createQuickImportButton() {
const button = document.createElement('button');
button.type = 'button';
button.className = QUICK_IMPORT_BUTTON_CLASS;
button.innerHTML = '<i class="fas fa-map"></i> <span>Quick import</span>';
button.addEventListener('click', handleQuickImportClick);
return button;
}
/**
* Handle click events on the quick import button.
* Creates the controller if needed and shows the import panel.
*/
function handleQuickImportClick() {
if (!sceneImportController) {
sceneImportController = new SceneImportController();
sceneImportController.initialize();
}
sceneImportController.showImportPanel();
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Myxeliums Battlemap Importer - Main Entry Point
*
* This module provides a streamlined way to import battlemaps into Foundry VTT.
* It adds a "Quick import" button to the Scenes sidebar that opens a drag-and-drop
* panel for creating scenes from images/videos and optional JSON configuration files.
*
* @module MyxeliumsBattlemap
* @author Myxelium
* @license MIT
*/
import { SceneImportController } from './lib/scene-import-controller.js';
/** @type {SceneImportController|null} Singleton instance of the import controller */
let sceneImportController = null;
/**
* Module identifier used for logging and namespacing
* @constant {string}
*/
const MODULE_ID = 'Myxeliums Battlemap Importer';
/**
* CSS class name for the quick import button to prevent duplicate insertion
* @constant {string}
*/
const QUICK_IMPORT_BUTTON_CLASS = 'myxeliums-battlemap-quick-import';
/**
* Initialize the module when Foundry is ready.
* Sets up the import controller and registers necessary handlers.
*/
Hooks.once('init', async function () {
console.log(`${MODULE_ID} | Initializing module`);
});
/**
* Complete module setup after Foundry is fully loaded.
* Creates the controller instance and displays a ready notification.
*/
Hooks.once('ready', async function () {
console.log(`${MODULE_ID} | Module ready`);
sceneImportController = new SceneImportController();
sceneImportController.initialize();
ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.Ready"));
});
/**
* Add the "Quick import" button to the Scenes directory header.
* This hook fires whenever the SceneDirectory is rendered.
*
* @param {Application} _app - The SceneDirectory application instance (unused)
* @param {jQuery|HTMLElement} html - The rendered HTML element
*/
Hooks.on('renderSceneDirectory', (_app, html) => {
// Only GMs can use the quick import feature
if (!game.user?.isGM) {
return;
}
// Handle different HTML element formats across Foundry versions
const rootElement = html?.[0] || html?.element || html;
if (!(rootElement instanceof HTMLElement)) {
return;
}
// Prevent adding duplicate buttons
const existingButton = rootElement.querySelector(`button.${QUICK_IMPORT_BUTTON_CLASS}`);
if (existingButton) {
return;
}
// Find a suitable container for the button
const buttonContainer = findButtonContainer(rootElement);
if (!buttonContainer) {
return;
}
// Create and append the quick import button
const quickImportButton = createQuickImportButton();
buttonContainer.appendChild(quickImportButton);
});
/**
* Find a suitable container element for the quick import button.
* Tries multiple selectors for compatibility across Foundry versions.
*
* @param {HTMLElement} rootElement - The root element to search within
* @returns {HTMLElement|null} The container element or null if not found
*/
function findButtonContainer(rootElement) {
const containerSelectors = [
'.header-actions',
'.action-buttons',
'.directory-header'
];
for (const selector of containerSelectors) {
const container = rootElement.querySelector(selector);
if (container) {
return container;
}
}
return null;
}
/**
* Create the quick import button element with icon and click handler.
*
* @returns {HTMLButtonElement} The configured button element
*/
function createQuickImportButton() {
const button = document.createElement('button');
button.type = 'button';
button.className = QUICK_IMPORT_BUTTON_CLASS;
button.innerHTML = '<i class="fas fa-map"></i> <span>Quick import</span>';
button.addEventListener('click', handleQuickImportClick);
return button;
}
/**
* Handle click events on the quick import button.
* Creates the controller if needed and shows the import panel.
*/
function handleQuickImportClick() {
if (!sceneImportController) {
sceneImportController = new SceneImportController();
sceneImportController.initialize();
}
sceneImportController.showImportPanel();
}
-48
View File
@@ -1,48 +0,0 @@
#easy-battlemap-drop-area {
margin-bottom: 10px;
background: rgba(0, 0, 0, 0.1);
border: 1px solid #7a7971;
border-radius: 5px;
}
#easy-battlemap-drop-area header {
background: rgba(0, 0, 0, 0.2);
padding: 5px;
border-bottom: 1px solid #7a7971;
text-align: center;
}
.easy-battlemap-dropzone {
padding: 15px;
text-align: center;
min-height: 120px;
display: flex;
flex-direction: column;
gap: 10px;
}
.easy-battlemap-instructions {
font-style: italic;
color: #777;
margin-bottom: 10px;
}
.easy-battlemap-status {
display: flex;
justify-content: space-around;
margin: 10px 0;
}
.easy-battlemap-status .status-value {
font-weight: bold;
}
.create-scene-button {
width: 80%;
margin: 0 auto;
}
#easy-battlemap-drop-area.highlight {
border-color: #ff6400;
background-color: rgba(255, 100, 0, 0.1);
}
+749
View File
@@ -0,0 +1,749 @@
/* ==========================================================================
Myxeliums Battlemap Importer - Modern Panel Styles
========================================================================== */
/* Quick Import Button (Foundry sidebar) - Keep original styling */
button.myxeliums-battlemap-quick-import {
flex: 0 0 auto;
margin-left: auto;
margin-right: auto;
display: block;
}
/* CSS Custom Properties */
:root {
--qbi-primary: #7c3aed;
--qbi-primary-hover: #6d28d9;
--qbi-primary-light: rgba(124, 58, 237, 0.1);
--qbi-success: #10b981;
--qbi-success-bg: rgba(16, 185, 129, 0.1);
--qbi-error: #ef4444;
--qbi-error-bg: rgba(239, 68, 68, 0.1);
--qbi-warning: #f59e0b;
--qbi-bg-dark: #1a1a2e;
--qbi-bg-card: #16213e;
--qbi-bg-elevated: #1f2b4d;
--qbi-border: rgba(255, 255, 255, 0.08);
--qbi-border-light: rgba(255, 255, 255, 0.12);
--qbi-text: #e2e8f0;
--qbi-text-muted: #94a3b8;
--qbi-text-dim: #64748b;
--qbi-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
--qbi-shadow-sm: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
--qbi-radius: 12px;
--qbi-radius-sm: 8px;
--qbi-radius-xs: 6px;
--qbi-transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Panel Container */
.qbi-panel {
position: absolute;
left: 72px;
top: 80px;
z-index: 100;
display: none;
width: 420px;
background: var(--qbi-bg-dark);
border-radius: var(--qbi-radius);
box-shadow: var(--qbi-shadow);
border: 1px solid var(--qbi-border);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
overflow: hidden;
}
/* Header */
.qbi-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: linear-gradient(
135deg,
var(--qbi-bg-elevated) 0%,
var(--qbi-bg-card) 100%
);
border-bottom: 1px solid var(--qbi-border);
cursor: move;
user-select: none;
}
.qbi-header-title {
display: flex;
align-items: center;
gap: 10px;
}
.qbi-header-icon {
font-size: 18px;
color: var(--qbi-primary);
}
.qbi-header h4 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--qbi-text);
letter-spacing: -0.01em;
}
.qbi-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: rgba(255, 255, 255, 0.05);
border-radius: var(--qbi-radius-xs);
color: var(--qbi-text-muted);
cursor: pointer;
transition: all var(--qbi-transition);
}
.qbi-close-btn:hover {
background: rgba(239, 68, 68, 0.2);
color: var(--qbi-error);
}
/* Content */
.qbi-content {
padding: 20px;
}
.qbi-content form {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Instructions */
.qbi-instructions {
margin: 0;
font-size: 13px;
color: var(--qbi-text-muted);
line-height: 1.5;
}
.qbi-instructions-secondary {
font-size: 12px;
color: var(--qbi-text-dim);
padding-top: 4px;
border-top: 1px solid var(--qbi-border);
}
/* Drop Zone */
.qbi-dropzone {
position: relative;
border: 2px dashed var(--qbi-border-light);
border-radius: var(--qbi-radius-sm);
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.03) 0%,
rgba(16, 213, 238, 0.03) 100%
);
transition: all var(--qbi-transition);
}
.qbi-dropzone:hover {
border-color: var(--qbi-primary);
background: var(--qbi-primary-light);
}
.qbi-dropzone-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 20px;
gap: 8px;
}
.qbi-dropzone-icon {
font-size: 32px;
color: var(--qbi-primary);
opacity: 0.8;
transition: all var(--qbi-transition);
}
.qbi-dropzone:hover .qbi-dropzone-icon {
opacity: 1;
transform: translateY(-2px);
}
.qbi-dropzone-text {
font-size: 15px;
font-weight: 500;
color: var(--qbi-text);
}
.qbi-dropzone-hint {
font-size: 12px;
color: var(--qbi-text-dim);
}
/* Highlight state when dragging */
.qbi-panel.highlight .qbi-dropzone {
border-color: var(--qbi-primary);
background: var(--qbi-primary-light);
box-shadow: 0 0 0 4px rgba(124, 58, 237, 0.15);
}
.qbi-panel.highlight .qbi-dropzone-icon {
color: var(--qbi-primary);
opacity: 1;
animation: qbi-bounce 0.6s ease infinite;
}
@keyframes qbi-bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
/* Status Grid */
.qbi-status-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.qbi-status-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: var(--qbi-bg-card);
border-radius: var(--qbi-radius-sm);
border: 1px solid var(--qbi-border);
transition: all var(--qbi-transition);
}
.qbi-status-indicator {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--qbi-error-bg);
transition: all var(--qbi-transition);
}
.qbi-status-icon {
font-size: 16px;
line-height: 1;
}
.qbi-status-item:has(.status-value[data-status="complete"])
.qbi-status-indicator {
background: var(--qbi-success-bg);
}
.qbi-status-label {
font-size: 12px;
font-weight: 500;
color: var(--qbi-text-muted);
line-height: 1.3;
}
/* Options / Checkbox */
.qbi-options {
padding: 4px 0;
}
.qbi-checkbox {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
}
.qbi-checkbox-input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.qbi-checkbox-mark {
position: relative;
width: 20px;
height: 20px;
background: var(--qbi-bg-card);
border: 2px solid var(--qbi-border-light);
border-radius: var(--qbi-radius-xs);
transition: all var(--qbi-transition);
}
.qbi-checkbox-mark::after {
content: "";
position: absolute;
top: 2px;
left: 6px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg) scale(0);
transition: transform var(--qbi-transition);
}
.qbi-checkbox-input:checked + .qbi-checkbox-mark {
background: var(--qbi-primary);
border-color: var(--qbi-primary);
}
.qbi-checkbox-input:checked + .qbi-checkbox-mark::after {
transform: rotate(45deg) scale(1);
}
.qbi-checkbox-input:focus + .qbi-checkbox-mark {
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.3);
}
.qbi-checkbox-label {
font-size: 13px;
color: var(--qbi-text-muted);
}
/* Progress Section */
.qbi-progress {
display: none;
padding: 14px 16px;
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.08) 0%,
rgba(124, 58, 237, 0.04) 100%
);
border-radius: var(--qbi-radius-sm);
border: 1px solid rgba(124, 58, 237, 0.2);
}
.qbi-progress-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.qbi-spinner {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--qbi-primary);
font-size: 18px;
}
.qbi-progress-info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.qbi-progress-text {
font-size: 13px;
font-weight: 500;
color: var(--qbi-text);
}
.qbi-progress-note {
font-size: 11px;
color: var(--qbi-text-dim);
margin: 0;
}
/* Footer / Buttons */
.qbi-footer {
display: flex;
gap: 10px;
padding: 12px 0px;
border-top: 1px solid var(--qbi-border);
margin-top: 4px;
}
.qbi-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 18px;
font-size: 13px;
font-weight: 500;
border-radius: var(--qbi-radius-sm);
border: none;
cursor: pointer;
transition: all var(--qbi-transition);
line-height: 1;
height: 50px;
}
.qbi-btn i {
font-size: 12px;
}
.qbi-btn-secondary {
flex: 0 0 25%;
background: var(--qbi-bg-elevated);
color: var(--qbi-text-muted);
border: 1px solid var(--qbi-border-light);
}
.qbi-btn-secondary:hover {
background: var(--qbi-bg-card);
color: var(--qbi-text);
border-color: var(--qbi-border-light);
}
.qbi-btn-primary {
flex: 1 1 75%;
background: linear-gradient(
135deg,
var(--qbi-primary) 0%,
var(--qbi-primary-hover) 100%
);
color: white;
box-shadow: 0 4px 14px rgba(124, 58, 237, 0.35);
}
.qbi-btn-primary:hover:not(:disabled) {
background: linear-gradient(
135deg,
var(--qbi-primary-hover) 0%,
#5b21b6 100%
);
box-shadow: 0 6px 20px rgba(124, 58, 237, 0.45);
transform: translateY(-1px);
}
.qbi-btn-primary:disabled {
background: var(--qbi-bg-elevated);
color: var(--qbi-text-dim);
box-shadow: none;
cursor: not-allowed;
opacity: 0.6;
}
/* Status update animations */
.qbi-status-icon {
transition: transform 0.3s ease;
}
.qbi-status-item:has(.status-value:not([data-status="pending"])) {
border-color: rgba(16, 185, 129, 0.3);
}
/* ==========================================================================
Floor Management Section
========================================================================== */
.qbi-floor-section {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 4px;
}
.qbi-floor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2px;
}
.qbi-floor-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
color: var(--qbi-text);
}
.qbi-floor-title i {
color: var(--qbi-primary);
font-size: 14px;
}
.qbi-floor-count {
font-size: 11px;
color: var(--qbi-text-dim);
background: var(--qbi-bg-card);
padding: 3px 8px;
border-radius: var(--qbi-radius-xs);
}
.qbi-floor-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 200px;
overflow-y: auto;
padding: 2px;
}
.qbi-floor-list::-webkit-scrollbar {
width: 6px;
}
.qbi-floor-list::-webkit-scrollbar-track {
background: var(--qbi-bg-card);
border-radius: 3px;
}
.qbi-floor-list::-webkit-scrollbar-thumb {
background: var(--qbi-border-light);
border-radius: 3px;
}
.qbi-floor-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px;
color: var(--qbi-text-dim);
font-size: 12px;
background: var(--qbi-bg-card);
border-radius: var(--qbi-radius-sm);
border: 1px dashed var(--qbi-border);
}
.qbi-floor-empty i {
font-size: 14px;
opacity: 0.7;
}
/* Floor Item */
.qbi-floor-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--qbi-bg-card);
border-radius: var(--qbi-radius-sm);
border: 1px solid var(--qbi-border);
transition: all var(--qbi-transition);
cursor: grab;
}
.qbi-floor-item:hover {
border-color: var(--qbi-border-light);
background: var(--qbi-bg-elevated);
}
.qbi-floor-item.qbi-floor-dragging {
opacity: 0.5;
cursor: grabbing;
}
.qbi-floor-item.qbi-floor-drag-over {
border-color: var(--qbi-primary);
background: var(--qbi-primary-light);
}
.qbi-floor-drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
color: var(--qbi-text-dim);
cursor: grab;
}
.qbi-floor-drag-handle:active {
cursor: grabbing;
}
.qbi-floor-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.qbi-floor-name {
display: flex;
align-items: center;
gap: 8px;
}
.qbi-floor-level {
font-size: 11px;
font-weight: 600;
color: var(--qbi-primary);
background: var(--qbi-primary-light);
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
}
.qbi-floor-filename {
font-size: 12px;
color: var(--qbi-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qbi-floor-json {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
}
.qbi-floor-json.has-json {
color: var(--qbi-success);
}
.qbi-floor-json.no-json {
color: var(--qbi-text-dim);
}
.qbi-floor-json i {
font-size: 10px;
}
.qbi-floor-actions {
display: flex;
gap: 4px;
}
.qbi-floor-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: var(--qbi-bg-elevated);
border-radius: var(--qbi-radius-xs);
color: var(--qbi-text-muted);
cursor: pointer;
transition: all var(--qbi-transition);
font-size: 11px;
}
.qbi-floor-btn:hover:not(:disabled) {
background: var(--qbi-border-light);
color: var(--qbi-text);
}
.qbi-floor-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.qbi-floor-btn.qbi-floor-remove:hover {
background: var(--qbi-error-bg);
color: var(--qbi-error);
}
/* Unmatched Files Section */
.qbi-unmatched-files {
margin-top: 8px;
padding: 12px;
background: rgba(245, 158, 11, 0.08);
border-radius: var(--qbi-radius-sm);
border: 1px solid rgba(245, 158, 11, 0.2);
}
.qbi-unmatched-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
color: var(--qbi-warning);
font-size: 12px;
font-weight: 500;
}
.qbi-unmatched-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.qbi-unmatched-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.qbi-unmatched-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--qbi-text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.qbi-unmatched-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: var(--qbi-bg-card);
border-radius: var(--qbi-radius-xs);
}
.qbi-unmatched-name {
font-size: 12px;
color: var(--qbi-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
margin-right: 8px;
}
.qbi-unmatched-assign {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: var(--qbi-primary-light);
border-radius: var(--qbi-radius-xs);
color: var(--qbi-primary);
cursor: pointer;
transition: all var(--qbi-transition);
}
.qbi-unmatched-assign:hover {
background: var(--qbi-primary);
color: white;
}
/* File Match Dialog Styles */
.qbi-match-dialog-content {
padding: 10px 0;
}
.qbi-match-dialog-content p {
margin-bottom: 12px;
color: var(--qbi-text);
}
.qbi-floor-select {
width: 100%;
padding: 8px 12px;
background: var(--qbi-bg-card);
border: 1px solid var(--qbi-border-light);
border-radius: var(--qbi-radius-xs);
color: var(--qbi-text);
font-size: 13px;
}
+749
View File
@@ -0,0 +1,749 @@
/* ==========================================================================
Myxeliums Battlemap Importer - Modern Panel Styles
========================================================================== */
/* Quick Import Button (Foundry sidebar) - Keep original styling */
button.myxeliums-battlemap-quick-import {
flex: 0 0 auto;
margin-left: auto;
margin-right: auto;
display: block;
}
/* CSS Custom Properties */
:root {
--qbi-primary: #7c3aed;
--qbi-primary-hover: #6d28d9;
--qbi-primary-light: rgba(124, 58, 237, 0.1);
--qbi-success: #10b981;
--qbi-success-bg: rgba(16, 185, 129, 0.1);
--qbi-error: #ef4444;
--qbi-error-bg: rgba(239, 68, 68, 0.1);
--qbi-warning: #f59e0b;
--qbi-bg-dark: #1a1a2e;
--qbi-bg-card: #16213e;
--qbi-bg-elevated: #1f2b4d;
--qbi-border: rgba(255, 255, 255, 0.08);
--qbi-border-light: rgba(255, 255, 255, 0.12);
--qbi-text: #e2e8f0;
--qbi-text-muted: #94a3b8;
--qbi-text-dim: #64748b;
--qbi-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
--qbi-shadow-sm: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
--qbi-radius: 12px;
--qbi-radius-sm: 8px;
--qbi-radius-xs: 6px;
--qbi-transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Panel Container */
.qbi-panel {
position: absolute;
left: 72px;
top: 80px;
z-index: 100;
display: none;
width: 420px;
background: var(--qbi-bg-dark);
border-radius: var(--qbi-radius);
box-shadow: var(--qbi-shadow);
border: 1px solid var(--qbi-border);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
overflow: hidden;
}
/* Header */
.qbi-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: linear-gradient(
135deg,
var(--qbi-bg-elevated) 0%,
var(--qbi-bg-card) 100%
);
border-bottom: 1px solid var(--qbi-border);
cursor: move;
user-select: none;
}
.qbi-header-title {
display: flex;
align-items: center;
gap: 10px;
}
.qbi-header-icon {
font-size: 18px;
color: var(--qbi-primary);
}
.qbi-header h4 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--qbi-text);
letter-spacing: -0.01em;
}
.qbi-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: rgba(255, 255, 255, 0.05);
border-radius: var(--qbi-radius-xs);
color: var(--qbi-text-muted);
cursor: pointer;
transition: all var(--qbi-transition);
}
.qbi-close-btn:hover {
background: rgba(239, 68, 68, 0.2);
color: var(--qbi-error);
}
/* Content */
.qbi-content {
padding: 20px;
}
.qbi-content form {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Instructions */
.qbi-instructions {
margin: 0;
font-size: 13px;
color: var(--qbi-text-muted);
line-height: 1.5;
}
.qbi-instructions-secondary {
font-size: 12px;
color: var(--qbi-text-dim);
padding-top: 4px;
border-top: 1px solid var(--qbi-border);
}
/* Drop Zone */
.qbi-dropzone {
position: relative;
border: 2px dashed var(--qbi-border-light);
border-radius: var(--qbi-radius-sm);
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.03) 0%,
rgba(16, 213, 238, 0.03) 100%
);
transition: all var(--qbi-transition);
}
.qbi-dropzone:hover {
border-color: var(--qbi-primary);
background: var(--qbi-primary-light);
}
.qbi-dropzone-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 20px;
gap: 8px;
}
.qbi-dropzone-icon {
font-size: 32px;
color: var(--qbi-primary);
opacity: 0.8;
transition: all var(--qbi-transition);
}
.qbi-dropzone:hover .qbi-dropzone-icon {
opacity: 1;
transform: translateY(-2px);
}
.qbi-dropzone-text {
font-size: 15px;
font-weight: 500;
color: var(--qbi-text);
}
.qbi-dropzone-hint {
font-size: 12px;
color: var(--qbi-text-dim);
}
/* Highlight state when dragging */
.qbi-panel.highlight .qbi-dropzone {
border-color: var(--qbi-primary);
background: var(--qbi-primary-light);
box-shadow: 0 0 0 4px rgba(124, 58, 237, 0.15);
}
.qbi-panel.highlight .qbi-dropzone-icon {
color: var(--qbi-primary);
opacity: 1;
animation: qbi-bounce 0.6s ease infinite;
}
@keyframes qbi-bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
/* Status Grid */
.qbi-status-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.qbi-status-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: var(--qbi-bg-card);
border-radius: var(--qbi-radius-sm);
border: 1px solid var(--qbi-border);
transition: all var(--qbi-transition);
}
.qbi-status-indicator {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--qbi-error-bg);
transition: all var(--qbi-transition);
}
.qbi-status-icon {
font-size: 16px;
line-height: 1;
}
.qbi-status-item:has(.status-value[data-status="complete"])
.qbi-status-indicator {
background: var(--qbi-success-bg);
}
.qbi-status-label {
font-size: 12px;
font-weight: 500;
color: var(--qbi-text-muted);
line-height: 1.3;
}
/* Options / Checkbox */
.qbi-options {
padding: 4px 0;
}
.qbi-checkbox {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
}
.qbi-checkbox-input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.qbi-checkbox-mark {
position: relative;
width: 20px;
height: 20px;
background: var(--qbi-bg-card);
border: 2px solid var(--qbi-border-light);
border-radius: var(--qbi-radius-xs);
transition: all var(--qbi-transition);
}
.qbi-checkbox-mark::after {
content: "";
position: absolute;
top: 2px;
left: 6px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg) scale(0);
transition: transform var(--qbi-transition);
}
.qbi-checkbox-input:checked + .qbi-checkbox-mark {
background: var(--qbi-primary);
border-color: var(--qbi-primary);
}
.qbi-checkbox-input:checked + .qbi-checkbox-mark::after {
transform: rotate(45deg) scale(1);
}
.qbi-checkbox-input:focus + .qbi-checkbox-mark {
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.3);
}
.qbi-checkbox-label {
font-size: 13px;
color: var(--qbi-text-muted);
}
/* Progress Section */
.qbi-progress {
display: none;
padding: 14px 16px;
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.08) 0%,
rgba(124, 58, 237, 0.04) 100%
);
border-radius: var(--qbi-radius-sm);
border: 1px solid rgba(124, 58, 237, 0.2);
}
.qbi-progress-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.qbi-spinner {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--qbi-primary);
font-size: 18px;
}
.qbi-progress-info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.qbi-progress-text {
font-size: 13px;
font-weight: 500;
color: var(--qbi-text);
}
.qbi-progress-note {
font-size: 11px;
color: var(--qbi-text-dim);
margin: 0;
}
/* Footer / Buttons */
.qbi-footer {
display: flex;
gap: 10px;
padding: 12px 0px;
border-top: 1px solid var(--qbi-border);
margin-top: 4px;
}
.qbi-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 18px;
font-size: 13px;
font-weight: 500;
border-radius: var(--qbi-radius-sm);
border: none;
cursor: pointer;
transition: all var(--qbi-transition);
line-height: 1;
height: 50px;
}
.qbi-btn i {
font-size: 12px;
}
.qbi-btn-secondary {
flex: 0 0 25%;
background: var(--qbi-bg-elevated);
color: var(--qbi-text-muted);
border: 1px solid var(--qbi-border-light);
}
.qbi-btn-secondary:hover {
background: var(--qbi-bg-card);
color: var(--qbi-text);
border-color: var(--qbi-border-light);
}
.qbi-btn-primary {
flex: 1 1 75%;
background: linear-gradient(
135deg,
var(--qbi-primary) 0%,
var(--qbi-primary-hover) 100%
);
color: white;
box-shadow: 0 4px 14px rgba(124, 58, 237, 0.35);
}
.qbi-btn-primary:hover:not(:disabled) {
background: linear-gradient(
135deg,
var(--qbi-primary-hover) 0%,
#5b21b6 100%
);
box-shadow: 0 6px 20px rgba(124, 58, 237, 0.45);
transform: translateY(-1px);
}
.qbi-btn-primary:disabled {
background: var(--qbi-bg-elevated);
color: var(--qbi-text-dim);
box-shadow: none;
cursor: not-allowed;
opacity: 0.6;
}
/* Status update animations */
.qbi-status-icon {
transition: transform 0.3s ease;
}
.qbi-status-item:has(.status-value:not([data-status="pending"])) {
border-color: rgba(16, 185, 129, 0.3);
}
/* ==========================================================================
Floor Management Section
========================================================================== */
.qbi-floor-section {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 4px;
}
.qbi-floor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2px;
}
.qbi-floor-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
color: var(--qbi-text);
}
.qbi-floor-title i {
color: var(--qbi-primary);
font-size: 14px;
}
.qbi-floor-count {
font-size: 11px;
color: var(--qbi-text-dim);
background: var(--qbi-bg-card);
padding: 3px 8px;
border-radius: var(--qbi-radius-xs);
}
.qbi-floor-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 200px;
overflow-y: auto;
padding: 2px;
}
.qbi-floor-list::-webkit-scrollbar {
width: 6px;
}
.qbi-floor-list::-webkit-scrollbar-track {
background: var(--qbi-bg-card);
border-radius: 3px;
}
.qbi-floor-list::-webkit-scrollbar-thumb {
background: var(--qbi-border-light);
border-radius: 3px;
}
.qbi-floor-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px;
color: var(--qbi-text-dim);
font-size: 12px;
background: var(--qbi-bg-card);
border-radius: var(--qbi-radius-sm);
border: 1px dashed var(--qbi-border);
}
.qbi-floor-empty i {
font-size: 14px;
opacity: 0.7;
}
/* Floor Item */
.qbi-floor-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--qbi-bg-card);
border-radius: var(--qbi-radius-sm);
border: 1px solid var(--qbi-border);
transition: all var(--qbi-transition);
cursor: grab;
}
.qbi-floor-item:hover {
border-color: var(--qbi-border-light);
background: var(--qbi-bg-elevated);
}
.qbi-floor-item.qbi-floor-dragging {
opacity: 0.5;
cursor: grabbing;
}
.qbi-floor-item.qbi-floor-drag-over {
border-color: var(--qbi-primary);
background: var(--qbi-primary-light);
}
.qbi-floor-drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
color: var(--qbi-text-dim);
cursor: grab;
}
.qbi-floor-drag-handle:active {
cursor: grabbing;
}
.qbi-floor-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.qbi-floor-name {
display: flex;
align-items: center;
gap: 8px;
}
.qbi-floor-level {
font-size: 11px;
font-weight: 600;
color: var(--qbi-primary);
background: var(--qbi-primary-light);
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
}
.qbi-floor-filename {
font-size: 12px;
color: var(--qbi-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qbi-floor-json {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
}
.qbi-floor-json.has-json {
color: var(--qbi-success);
}
.qbi-floor-json.no-json {
color: var(--qbi-text-dim);
}
.qbi-floor-json i {
font-size: 10px;
}
.qbi-floor-actions {
display: flex;
gap: 4px;
}
.qbi-floor-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: var(--qbi-bg-elevated);
border-radius: var(--qbi-radius-xs);
color: var(--qbi-text-muted);
cursor: pointer;
transition: all var(--qbi-transition);
font-size: 11px;
}
.qbi-floor-btn:hover:not(:disabled) {
background: var(--qbi-border-light);
color: var(--qbi-text);
}
.qbi-floor-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.qbi-floor-btn.qbi-floor-remove:hover {
background: var(--qbi-error-bg);
color: var(--qbi-error);
}
/* Unmatched Files Section */
.qbi-unmatched-files {
margin-top: 8px;
padding: 12px;
background: rgba(245, 158, 11, 0.08);
border-radius: var(--qbi-radius-sm);
border: 1px solid rgba(245, 158, 11, 0.2);
}
.qbi-unmatched-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
color: var(--qbi-warning);
font-size: 12px;
font-weight: 500;
}
.qbi-unmatched-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.qbi-unmatched-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.qbi-unmatched-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--qbi-text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.qbi-unmatched-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: var(--qbi-bg-card);
border-radius: var(--qbi-radius-xs);
}
.qbi-unmatched-name {
font-size: 12px;
color: var(--qbi-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
margin-right: 8px;
}
.qbi-unmatched-assign {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: var(--qbi-primary-light);
border-radius: var(--qbi-radius-xs);
color: var(--qbi-primary);
cursor: pointer;
transition: all var(--qbi-transition);
}
.qbi-unmatched-assign:hover {
background: var(--qbi-primary);
color: white;
}
/* File Match Dialog Styles */
.qbi-match-dialog-content {
padding: 10px 0;
}
.qbi-match-dialog-content p {
margin-bottom: 12px;
color: var(--qbi-text);
}
.qbi-floor-select {
width: 100%;
padding: 8px 12px;
background: var(--qbi-bg-card);
border: 1px solid var(--qbi-border-light);
border-radius: var(--qbi-radius-xs);
color: var(--qbi-text);
font-size: 13px;
}