diff --git a/README.md b/README.md index c2afa66..a4024cf 100644 --- a/README.md +++ b/README.md @@ -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 ready‑to‑play 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 +image +image + + + +### 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. \ No newline at end of file +MIT diff --git a/languages/en.json b/languages/en.json index a7db2db..562e722 100644 --- a/languages/en.json +++ b/languages/en.json @@ -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" } } \ No newline at end of file diff --git a/module.json b/module.json index 31db47b..3b23f50 100644 --- a/module.json +++ b/module.json @@ -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" } diff --git a/scripts/easy-battlemap.js b/scripts/easy-battlemap.js deleted file mode 100644 index bcf1587..0000000 --- a/scripts/easy-battlemap.js +++ /dev/null @@ -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")); -}); \ No newline at end of file diff --git a/scripts/lib/drop-handler.js b/scripts/lib/drop-handler.js deleted file mode 100644 index 195d433..0000000 --- a/scripts/lib/drop-handler.js +++ /dev/null @@ -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 = ` -
-
-

${game.i18n.localize("EASYBATTLEMAP.DropAreaTitle")}

-
-
-
- ${game.i18n.localize("EASYBATTLEMAP.DropInstructions")} -
-
-
- ${game.i18n.localize("EASYBATTLEMAP.BackgroundStatus")}: -
-
- ${game.i18n.localize("EASYBATTLEMAP.WallDataStatus")}: -
-
- -
-
- `; - - // 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(); - } -} \ No newline at end of file diff --git a/scripts/lib/file-processor.js b/scripts/lib/file-processor.js new file mode 100644 index 0000000..a4e6051 --- /dev/null +++ b/scripts/lib/file-processor.js @@ -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} 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} 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 }; + } + } +} diff --git a/scripts/lib/grid-detection-service.js b/scripts/lib/grid-detection-service.js new file mode 100644 index 0000000..83fcade --- /dev/null +++ b/scripts/lib/grid-detection-service.js @@ -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} 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} 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 + }; + } +} diff --git a/scripts/lib/import-panel-view.js b/scripts/lib/import-panel-view.js new file mode 100644 index 0000000..6750dfe --- /dev/null +++ b/scripts/lib/import-panel-view.js @@ -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 ` + `; + } + + /** + * 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; + } + } +} diff --git a/scripts/lib/media-storage-service.js b/scripts/lib/media-storage-service.js new file mode 100644 index 0000000..6e99bb1 --- /dev/null +++ b/scripts/lib/media-storage-service.js @@ -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} 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} 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}`); + } +} diff --git a/scripts/lib/scene-builder.js b/scripts/lib/scene-builder.js new file mode 100644 index 0000000..d6bc56f --- /dev/null +++ b/scripts/lib/scene-builder.js @@ -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} 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} 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} 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} + */ + 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} + */ + 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} Resolves after the delay + */ + delay(milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)); + } +} diff --git a/scripts/lib/scene-data-normalizer.js b/scripts/lib/scene-data-normalizer.js new file mode 100644 index 0000000..fe37f46 --- /dev/null +++ b/scripts/lib/scene-data-normalizer.js @@ -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; + } +} diff --git a/scripts/lib/scene-import-controller.js b/scripts/lib/scene-import-controller.js new file mode 100644 index 0000000..29bd7ef --- /dev/null +++ b/scripts/lib/scene-import-controller.js @@ -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); + } +} diff --git a/scripts/lib/signal-processing-utils.js b/scripts/lib/signal-processing-utils.js new file mode 100644 index 0000000..1d9d888 --- /dev/null +++ b/scripts/lib/signal-processing-utils.js @@ -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; +} diff --git a/scripts/quick-battlemap.js b/scripts/quick-battlemap.js new file mode 100644 index 0000000..25b9a5e --- /dev/null +++ b/scripts/quick-battlemap.js @@ -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 = ' Quick import'; + + 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(); +} diff --git a/styles/easy-battlemap.css b/styles/easy-battlemap.css deleted file mode 100644 index 6668030..0000000 --- a/styles/easy-battlemap.css +++ /dev/null @@ -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); -} \ No newline at end of file diff --git a/styles/quick-battlemap.css b/styles/quick-battlemap.css new file mode 100644 index 0000000..22a4bb4 --- /dev/null +++ b/styles/quick-battlemap.css @@ -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; +} \ No newline at end of file