1 Commits

Author SHA1 Message Date
Myx
569ed79884 giga refacotr 2026-01-08 02:02:02 +01:00
16 changed files with 2967 additions and 381 deletions

112
README.md
View File

@@ -1,59 +1,85 @@
# Easy Battlemap for Foundry VTT
![GitHub Downloads (specific asset, all releases)](https://img.shields.io/github/downloads/Myxelium/FoundryVTT-Quick-Import/quick-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
Quick 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="https://github.com/user-attachments/assets/a848543f-7a96-439a-8897-4971cf8a4cb5" />
<img width="538" height="422" alt="image" src="https://github.com/user-attachments/assets/d7672c2e-d241-4ced-8f6f-b5479e522287" />
### 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 10, verified 12
## 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"
3. Paste the following URL in the "Manifest URL" field:
`https://github.com/MyxeliumI/easy-battlemap/releases/latest/download/module.json`
3. Paste this Manifest URL:
https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/module.json
4. Click "Install"
Manual download (optional):
- Download ZIP: https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/quick-battlemap-importer.zip
## Usage
1. Enable the module in your game world
2. Navigate to the "Scenes" tab
3. You'll see a new "Easy Battlemap Creator" panel
4. Drag and drop your background image (jpg, png)
5. Drag and drop your JSON file with wall data
6. Once both files are loaded, click "Create Battlemap Scene"
1. Enable the module in your world
2. Open the Scenes sidebar
3. Click the "Quick import" button (GM only)
4. In the panel, drag and drop one of the following:
- Background image (png, jpg, jpeg) or video (webm, mp4)
- 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
{
"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
]
}
```
- 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
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
- 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
## Credits
- Author: Myxelium (https://github.com/Myxelium)
## License
This module is licensed under the MIT License.
MIT

View File

@@ -1,16 +1,28 @@
{
"EASYBATTLEMAP": {
"Ready": "Easy Battlemap module is ready",
"DropAreaTitle": "Easy Battlemap Creator",
"DropInstructions": "Drop a background image and a JSON file with wall data here",
"BackgroundStatus": "Background Image",
"WallDataStatus": "Wall Data",
"CreateScene": "Create Battlemap Scene",
"MissingFiles": "Both background image and wall data JSON are required",
"CreatingScene": "Creating battlemap scene...",
"SceneCreated": "Battlemap scene created",
"UploadFailed": "Failed to upload background image",
"SceneCreationFailed": "Failed to create scene",
"InvalidJSON": "The JSON file could not be parsed"
"QUICKBATTLEMAP": {
"Ready": "Quick Battlemap module is ready",
"DropAreaTitle": "Quick Battlemap Importer",
"DropInstructions": "Drop a background image or video, and optionally a JSON file with walls",
"DropInstructionsMore": "If no JSON is provided, the module can try to auto-detect grid size from the image. You can also choose to create a scene with no grid.",
"BackgroundStatus": "Background Media",
"WallDataStatus": "Wall Data",
"CreateScene": "Create Battlemap Scene",
"MissingFiles": "A background image or video is required",
"CreatingScene": "Creating battlemap scene...",
"SceneCreated": "Battlemap scene created",
"UploadFailed": "Failed to upload background media",
"SceneCreationFailed": "Failed to create scene",
"InvalidJSON": "The JSON file could not be parsed",
"DefaultSceneName": "New Battlemap",
"ControlTitle": "Quick 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"
}
}

View File

@@ -1,11 +1,11 @@
{
"id": "easy-battlemap",
"title": "Easy Battlemap",
"description": "Create battlemaps by simply dragging in a background image and wall data JSON file",
"version": "1.0.0",
"id": "quick-battlemap-importer",
"title": "Quick Battlemap Importer",
"description": "Import battlemaps by simply dragging in a background image and wall/light data JSON file",
"version": "1.4.2",
"compatibility": {
"minimum": "10",
"verified": "11"
"verified": "12"
},
"authors": [
{
@@ -13,8 +13,8 @@
"url": "https://github.com/Myxelium"
}
],
"esmodules": ["scripts/easy-battlemap.js"],
"styles": ["styles/easy-battlemap.css"],
"esmodules": ["scripts/quick-battlemap.js"],
"styles": ["styles/quick-battlemap.css"],
"languages": [
{
"lang": "en",
@@ -22,7 +22,7 @@
"path": "languages/en.json"
}
],
"url": "https://github.com/Myxelium/test-module",
"manifest": "https://github.com/Myxelium/test-module/releases/latest/download/module.json",
"download": "https://github.com/Myxelium/test-module/releases/latest/download/easy-battlemap.zip"
"url": "https://github.com/Myxelium/FoundryVTT-Quick-Import",
"manifest": "https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/module.json",
"download": "https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/quick-battlemap-importer.zip"
}

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"));
});

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();
}
}

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 = 'Quick 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 };
}
}
}

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
};
}
}

View File

@@ -0,0 +1,461 @@
/**
* 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: '#quick-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'
};
/** LocalStorage key for persisting no-grid preference */
const NO_GRID_STORAGE_KEY = 'quick-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;
}
/**
* 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('quick-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 Foundry's built-in styles and i18n for localization.
*
* @returns {string} The complete panel HTML
*/
buildPanelHtml() {
const i18n = (key) => game.i18n.localize(key);
const closeText = i18n('Close') ?? 'Close';
return `
<div id="quick-battlemap-drop-area" class="app window-app quick-battlemap"
style="left:72px; top:80px; z-index:100; display:none; width: 520px; position: absolute;">
<header class="window-header flexrow draggable">
<h4 class="window-title">${i18n('QUICKBATTLEMAP.DropAreaTitle')}</h4>
<a class="header-button control close"><i class="fas fa-times"></i>${closeText}</a>
</header>
<section class="window-content">
<form class="flexcol" autocomplete="off">
<p class="notes quick-battlemap-instructions">${i18n('QUICKBATTLEMAP.DropInstructions')}</p>
<div class="area"><div id="dropZone">Drop files here</div></div>
<p class="notes quick-battlemap-instructions">${i18n('QUICKBATTLEMAP.DropInstructionsMore')}</p>
<div class="form-group">
<label>${i18n('QUICKBATTLEMAP.BackgroundStatus')}</label>
<div class="form-fields"><span class="background-status"><span class="status-value">❌</span></span></div>
</div>
<div class="form-group">
<label>${i18n('QUICKBATTLEMAP.WallDataStatus')}</label>
<div class="form-fields"><span class="wall-data-status"><span class="status-value">❌</span></span></div>
</div>
<div class="form-group">
<label>${i18n('QUICKBATTLEMAP.Options')}</label>
<div class="form-fields ebm-no-grid">
<label class="checkbox"><input type="checkbox" class="ebm-no-grid" /><span>${i18n('QUICKBATTLEMAP.NoGridLabel')}</span></label>
</div>
</div>
<div class="form-group progress-group" style="display:none">
<label>${i18n('QUICKBATTLEMAP.ProgressLabel')}</label>
<div class="form-fields ebm-progress-row">
<div class="ebm-spinner" aria-hidden="true"><i class="fas fa-spinner fa-spin"></i></div>
<span class="ebm-progress-text">${i18n('QUICKBATTLEMAP.ProgressIdle')}</span>
</div>
<p class="notes ebm-progress-note">${i18n('QUICKBATTLEMAP.ProgressNote')}</p>
</div>
<footer class="sheet-footer flexrow">
<button type="button" class="reset-button"><i class="fas fa-undo"></i> ${i18n('QUICKBATTLEMAP.Reset')}</button>
<button type="button" class="create-scene-button" disabled><i class="fas fa-save"></i> ${i18n('QUICKBATTLEMAP.CreateScene')}</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('quick-battlemap-drop-area');
}
/**
* Show the import panel.
*/
show() {
this.ensureCreated();
const panel = this.getPanelElement();
if (panel) {
panel.style.display = '';
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 || '';
}
}
/**
* 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 || '';
}
}
/**
* 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;
}
}
}

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}`);
}
}

View File

@@ -0,0 +1,355 @@
/**
* 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.');
}
}
/**
* 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));
}
}

View File

@@ -0,0 +1,355 @@
/**
* 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 || []),
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.
*
* @param {Array} wallsArray - Array of raw wall data objects
* @returns {NormalizedWallData[]} Array of normalized wall documents
*/
normalizeWallsData(wallsArray) {
return wallsArray.map(wall => this.normalizeWall(wall));
}
/**
* 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();
return {
c: this.normalizeWallCoordinates(wall.c),
door: this.ensureFiniteNumber(wall.door, 0),
ds: this.ensureFiniteNumber(wall.ds, 0),
dir: this.ensureFiniteNumber(wall.dir, 0),
move: this.parseRestrictionValue(wall.move, restrictionTypes.NONE, restrictionTypes),
sound: this.parseRestrictionValue(wall.sound, restrictionTypes.NONE, restrictionTypes),
sight: this.parseRestrictionValue(wall.sense ?? wall.sight, restrictionTypes.NONE, restrictionTypes),
light: this.parseRestrictionValue(wall.light, restrictionTypes.NONE, 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
* @returns {NormalizedLightData[]} Array of normalized light documents
*/
normalizeLightsData(lightsArray) {
return lightsArray.map(light => this.normalizeLight(light));
}
/**
* Normalize a single light object to Foundry's expected format.
*
* @param {Object} light - Raw light data
* @returns {NormalizedLightData} Normalized light document
*/
normalizeLight(light) {
return {
x: Number(light.x),
y: Number(light.y),
rotation: 0,
hidden: false,
walls: true,
vision: false,
config: {
alpha: Number(light.tintAlpha ?? 0),
color: light.tintColor ?? null,
bright: Number(light.bright ?? 0),
dim: Number(light.dim ?? 0),
angle: 360
}
};
}
/**
* 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;
}
}

View File

@@ -0,0 +1,445 @@
/**
* 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
*/
/** 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';
/**
* 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 */
this.backgroundMediaData = null;
/** @type {Object|null} Parsed scene configuration from JSON */
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;
// 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);
}
/**
* 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) return;
for (let i = 0; i < droppedFiles.length; i++) {
const file = droppedFiles[i];
const fileType = this.fileProcessor.getFileType(file);
switch (fileType) {
case 'image':
this.handleImageFile(file);
break;
case 'video':
this.handleVideoFile(file);
break;
case 'json':
this.handleJsonConfigFile(file);
break;
}
}
}
/**
* 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) {
createButton.disabled = !this.backgroundMediaData;
}
}
/**
* Main scene creation workflow.
*/
async executeSceneCreation() {
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"));
}
}
/**
* 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
* @returns {string} The scene name to use
*/
determineSceneName(configuredName) {
if (configuredName) return configuredName;
const nameFromFile = this.backgroundMediaData.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}`);
}
/**
* 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() {
this.fileProcessor.revokeBlobUrl(this.backgroundMediaData?.data);
this.backgroundMediaData = null;
this.importedSceneStructure = null;
this.pendingOperationCount = 0;
this.isNoGridModeEnabled = this.loadNoGridPreference();
this.panelView.resetAllStatuses(this.isNoGridModeEnabled);
}
}

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
scripts/quick-battlemap.js Normal file
View File

@@ -0,0 +1,139 @@
/**
* Quick 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 QuickBattlemap
* @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 = 'Quick Battlemap';
/**
* CSS class name for the quick import button to prevent duplicate insertion
* @constant {string}
*/
const QUICK_IMPORT_BUTTON_CLASS = 'quick-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();
}

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);
}

View File

@@ -0,0 +1,64 @@
/* Keep it minimal and lean on Foundry's built-in styles */
#quick-battlemap-drop-area .window-header {
user-select: none;
cursor: move;
}
#quick-battlemap-drop-area .quick-battlemap-instructions {
font-style: italic;
margin: 0 0 0.5rem 0;
border-bottom: 1px solid var(--color-border-light-primary);
}
#quick-battlemap-drop-area .status-value {
font-weight: bold;
}
#quick-battlemap-drop-area .create-scene-button {
margin-left: auto;
}
/* Drag-and-drop highlight */
#quick-battlemap-drop-area.highlight .window-content {
outline: 2px solid var(--color-border-highlight, #ff6400);
background-color: rgba(255, 100, 0, 0.08);
}
/* Spinner row */
#quick-battlemap-drop-area .ebm-progress-row {
align-items: center;
gap: 8px;
}
#quick-battlemap-drop-area .ebm-spinner {
width: 20px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary, #ff6400);
}
#quick-battlemap-drop-area .ebm-no-grid {
justify-content: unset !important;
}
#quick-battlemap-drop-area .ebm-no-grid input {
margin: 0px 0px !important;
}
#quick-battlemap-drop-area .area {
width: 100%;
padding: 15px;
border: 1px solid #333;
background: rgba(0, 0, 0, 0.7);
}
#quick-battlemap-drop-area #dropZone {
border: 2px dashed #bbb;
-webkit-border-radius: 5px;
border-radius: 5px;
padding: 50px;
text-align: center;
font: 21pt bold arial;
color: #bbb;
}