mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2025-12-16 17:31:24 +01:00
This commit adds a comprehensive external API system that allows external tools (wikis, web UIs, etc.) to control and synchronize with Fantasy Map Generator. New Features: - External API Bridge (modules/external-api.js) - Event-driven architecture with EventEmitter - Map lifecycle control (create, load, save) - Data access methods (rivers, cultures, states, burgs) - Data mutation methods with auto-redraw - Export support (SVG, PNG, JSON) - Change detection with automatic event emission - PostMessage Communication Layer - Auto-enables when FMG is embedded in iframe - Bidirectional message passing - Request/response pattern with promise support - Automatic event forwarding to parent window - REST API Server (api-server/) - Express.js server with full CRUD operations - WebSocket support via Socket.IO for real-time updates - File upload support for map and CSV import - In-memory storage (can be replaced with database) - CORS enabled for cross-origin requests - Comprehensive endpoints for all map data - Client Library (api-server/client.js) - Simple JavaScript client for REST API - Promise-based async methods - Works in browser and Node.js - Demo Pages (demos/) - PostMessage integration demo with full UI - REST API demo with interactive testing - WebSocket demo for real-time events - Documentation - Comprehensive integration guide (EXTERNAL_API_INTEGRATION.md) - API reference with TypeScript interfaces - Multiple integration examples - Troubleshooting guide Integration Methods: 1. PostMessage Bridge - For iframe embedding 2. REST API - For server-side integration 3. Direct JavaScript API - For same-origin apps Use Cases: - Wiki pages that need to display and control maps - Web UIs that want to edit map data - External tools that need to sync with FMG - Real-time collaborative map editing - Batch operations and automation Technical Details: - Zero dependencies for external-api.js (pure JS) - Auto-initializes on DOMContentLoaded - Throttled change detection (500ms debounce) - Deep cloning for data access (prevents mutations) - Error handling throughout - Version tagged (v1.0.0) Updated Files: - index.html: Added script tag to load external-api module All APIs are backward compatible and don't affect existing functionality.
770 lines
20 KiB
JavaScript
770 lines
20 KiB
JavaScript
/**
|
|
* External API Bridge for Fantasy Map Generator
|
|
* Provides a clean interface for external tools (wikis, web UIs) to interact with FMG
|
|
* @module external-api
|
|
* @version 1.0.0
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// ============================================================================
|
|
// EVENT EMITTER SYSTEM
|
|
// ============================================================================
|
|
|
|
class EventEmitter {
|
|
constructor() {
|
|
this.events = {};
|
|
}
|
|
|
|
on(event, callback) {
|
|
if (!this.events[event]) {
|
|
this.events[event] = [];
|
|
}
|
|
this.events[event].push(callback);
|
|
return () => this.off(event, callback);
|
|
}
|
|
|
|
off(event, callback) {
|
|
if (!this.events[event]) return;
|
|
this.events[event] = this.events[event].filter(cb => cb !== callback);
|
|
}
|
|
|
|
emit(event, data) {
|
|
if (!this.events[event]) return;
|
|
this.events[event].forEach(callback => {
|
|
try {
|
|
callback(data);
|
|
} catch (error) {
|
|
console.error(`Error in event handler for ${event}:`, error);
|
|
}
|
|
});
|
|
}
|
|
|
|
once(event, callback) {
|
|
const wrapper = (data) => {
|
|
callback(data);
|
|
this.off(event, wrapper);
|
|
};
|
|
this.on(event, wrapper);
|
|
}
|
|
}
|
|
|
|
const eventEmitter = new EventEmitter();
|
|
|
|
// ============================================================================
|
|
// STATE MANAGEMENT & CHANGE DETECTION
|
|
// ============================================================================
|
|
|
|
let isInitialized = false;
|
|
let lastState = null;
|
|
let changeDetectionEnabled = true;
|
|
|
|
function initializeChangeDetection() {
|
|
if (isInitialized) return;
|
|
|
|
// Observe SVG changes for map updates
|
|
const mapElement = document.getElementById('map');
|
|
if (mapElement) {
|
|
const observer = new MutationObserver(debounce(() => {
|
|
if (changeDetectionEnabled) {
|
|
eventEmitter.emit('map:changed', getMapState());
|
|
}
|
|
}, 500));
|
|
|
|
observer.observe(mapElement, {
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true,
|
|
attributeFilter: ['d', 'transform', 'points']
|
|
});
|
|
}
|
|
|
|
isInitialized = true;
|
|
}
|
|
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// CORE API METHODS
|
|
// ============================================================================
|
|
|
|
const API = {
|
|
|
|
// ------------------------------------------------------------------------
|
|
// MAP LIFECYCLE
|
|
// ------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create a new map with optional parameters
|
|
* @param {Object} options - Generation options
|
|
* @returns {Promise<Object>} Map state
|
|
*/
|
|
async createMap(options = {}) {
|
|
try {
|
|
changeDetectionEnabled = false;
|
|
|
|
if (options.seed) {
|
|
seed = options.seed;
|
|
}
|
|
|
|
// Use existing generate function
|
|
await generate(options);
|
|
|
|
changeDetectionEnabled = true;
|
|
eventEmitter.emit('map:created', getMapState());
|
|
|
|
return {
|
|
success: true,
|
|
state: getMapState()
|
|
};
|
|
} catch (error) {
|
|
changeDetectionEnabled = true;
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Load map from data
|
|
* @param {String|Blob|File} mapData - Map data to load
|
|
* @returns {Promise<Object>} Load result
|
|
*/
|
|
async loadMap(mapData) {
|
|
try {
|
|
changeDetectionEnabled = false;
|
|
|
|
let file;
|
|
if (typeof mapData === 'string') {
|
|
// Convert string data to Blob
|
|
file = new Blob([mapData], {type: 'text/plain'});
|
|
} else {
|
|
file = mapData;
|
|
}
|
|
|
|
await new Promise((resolve, reject) => {
|
|
uploadMap(file, (error) => {
|
|
if (error) reject(error);
|
|
else resolve();
|
|
});
|
|
});
|
|
|
|
changeDetectionEnabled = true;
|
|
eventEmitter.emit('map:loaded', getMapState());
|
|
|
|
return {
|
|
success: true,
|
|
state: getMapState()
|
|
};
|
|
} catch (error) {
|
|
changeDetectionEnabled = true;
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Save current map
|
|
* @param {String} format - 'data' or 'blob'
|
|
* @returns {Promise<Object>} Saved map data
|
|
*/
|
|
async saveMap(format = 'data') {
|
|
try {
|
|
const mapData = await prepareMapData();
|
|
|
|
if (format === 'blob') {
|
|
return {
|
|
success: true,
|
|
data: new Blob([mapData], {type: 'text/plain'}),
|
|
filename: getFileName() + '.map'
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: mapData,
|
|
filename: getFileName() + '.map'
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
},
|
|
|
|
// ------------------------------------------------------------------------
|
|
// DATA ACCESS
|
|
// ------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get complete map state
|
|
* @returns {Object} Current map state
|
|
*/
|
|
getMapState() {
|
|
return getMapState();
|
|
},
|
|
|
|
/**
|
|
* Get specific data structure
|
|
* @param {String} key - Data key (rivers, cultures, states, burgs, etc.)
|
|
* @returns {*} Requested data
|
|
*/
|
|
getData(key) {
|
|
if (!pack || !pack[key]) {
|
|
return null;
|
|
}
|
|
return JSON.parse(JSON.stringify(pack[key]));
|
|
},
|
|
|
|
/**
|
|
* Get rivers data
|
|
* @returns {Array} Rivers array
|
|
*/
|
|
getRivers() {
|
|
return pack.rivers ? JSON.parse(JSON.stringify(pack.rivers)) : [];
|
|
},
|
|
|
|
/**
|
|
* Get cultures data
|
|
* @returns {Array} Cultures array
|
|
*/
|
|
getCultures() {
|
|
return pack.cultures ? JSON.parse(JSON.stringify(pack.cultures)) : [];
|
|
},
|
|
|
|
/**
|
|
* Get states data
|
|
* @returns {Array} States array
|
|
*/
|
|
getStates() {
|
|
return pack.states ? JSON.parse(JSON.stringify(pack.states)) : [];
|
|
},
|
|
|
|
/**
|
|
* Get burgs (cities/towns) data
|
|
* @returns {Array} Burgs array
|
|
*/
|
|
getBurgs() {
|
|
return pack.burgs ? JSON.parse(JSON.stringify(pack.burgs)) : [];
|
|
},
|
|
|
|
/**
|
|
* Get religions data
|
|
* @returns {Array} Religions array
|
|
*/
|
|
getReligions() {
|
|
return pack.religions ? JSON.parse(JSON.stringify(pack.religions)) : [];
|
|
},
|
|
|
|
/**
|
|
* Get markers data
|
|
* @returns {Array} Markers array
|
|
*/
|
|
getMarkers() {
|
|
return pack.markers ? JSON.parse(JSON.stringify(pack.markers)) : [];
|
|
},
|
|
|
|
/**
|
|
* Get grid data
|
|
* @returns {Object} Grid object with cells data
|
|
*/
|
|
getGrid() {
|
|
if (!grid) return null;
|
|
|
|
return {
|
|
spacing: grid.spacing,
|
|
cellsX: grid.cellsX,
|
|
cellsY: grid.cellsY,
|
|
features: grid.features,
|
|
boundary: grid.boundary
|
|
};
|
|
},
|
|
|
|
// ------------------------------------------------------------------------
|
|
// MUTATIONS
|
|
// ------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Update rivers data
|
|
* @param {Array} rivers - New rivers array
|
|
* @returns {Object} Update result
|
|
*/
|
|
updateRivers(rivers) {
|
|
try {
|
|
changeDetectionEnabled = false;
|
|
|
|
pack.rivers = rivers;
|
|
|
|
// Redraw rivers
|
|
if (window.Rivers && Rivers.specify) {
|
|
Rivers.specify();
|
|
}
|
|
if (typeof drawRivers === 'function') {
|
|
drawRivers();
|
|
}
|
|
|
|
changeDetectionEnabled = true;
|
|
eventEmitter.emit('rivers:updated', pack.rivers);
|
|
|
|
return {success: true};
|
|
} catch (error) {
|
|
changeDetectionEnabled = true;
|
|
return {success: false, error: error.message};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update cultures data
|
|
* @param {Array} cultures - New cultures array
|
|
* @returns {Object} Update result
|
|
*/
|
|
updateCultures(cultures) {
|
|
try {
|
|
changeDetectionEnabled = false;
|
|
|
|
pack.cultures = cultures;
|
|
|
|
// Redraw cultures
|
|
if (typeof drawCultures === 'function') {
|
|
drawCultures();
|
|
}
|
|
|
|
changeDetectionEnabled = true;
|
|
eventEmitter.emit('cultures:updated', pack.cultures);
|
|
|
|
return {success: true};
|
|
} catch (error) {
|
|
changeDetectionEnabled = true;
|
|
return {success: false, error: error.message};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update states data
|
|
* @param {Array} states - New states array
|
|
* @returns {Object} Update result
|
|
*/
|
|
updateStates(states) {
|
|
try {
|
|
changeDetectionEnabled = false;
|
|
|
|
pack.states = states;
|
|
|
|
// Redraw states
|
|
if (typeof drawStates === 'function') {
|
|
drawStates();
|
|
}
|
|
if (typeof drawBorders === 'function') {
|
|
drawBorders();
|
|
}
|
|
|
|
changeDetectionEnabled = true;
|
|
eventEmitter.emit('states:updated', pack.states);
|
|
|
|
return {success: true};
|
|
} catch (error) {
|
|
changeDetectionEnabled = true;
|
|
return {success: false, error: error.message};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update burgs (cities/towns) data
|
|
* @param {Array} burgs - New burgs array
|
|
* @returns {Object} Update result
|
|
*/
|
|
updateBurgs(burgs) {
|
|
try {
|
|
changeDetectionEnabled = false;
|
|
|
|
pack.burgs = burgs;
|
|
|
|
// Redraw burgs
|
|
if (typeof drawBurgs === 'function') {
|
|
drawBurgs();
|
|
}
|
|
|
|
changeDetectionEnabled = true;
|
|
eventEmitter.emit('burgs:updated', pack.burgs);
|
|
|
|
return {success: true};
|
|
} catch (error) {
|
|
changeDetectionEnabled = true;
|
|
return {success: false, error: error.message};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Add a new burg (city/town)
|
|
* @param {Object} burgData - Burg properties
|
|
* @returns {Object} Result with new burg ID
|
|
*/
|
|
addBurg(burgData) {
|
|
try {
|
|
const newId = pack.burgs.length;
|
|
const burg = {
|
|
i: newId,
|
|
cell: burgData.cell || 0,
|
|
x: burgData.x || 0,
|
|
y: burgData.y || 0,
|
|
name: burgData.name || Names.getCulture(burgData.culture || 0),
|
|
population: burgData.population || 1,
|
|
type: burgData.type || 'town',
|
|
...burgData
|
|
};
|
|
|
|
pack.burgs.push(burg);
|
|
|
|
if (typeof drawBurgs === 'function') {
|
|
drawBurgs();
|
|
}
|
|
|
|
eventEmitter.emit('burg:added', burg);
|
|
|
|
return {success: true, id: newId, burg};
|
|
} catch (error) {
|
|
return {success: false, error: error.message};
|
|
}
|
|
},
|
|
|
|
// ------------------------------------------------------------------------
|
|
// EXPORT
|
|
// ------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Export map as SVG
|
|
* @returns {String} SVG string
|
|
*/
|
|
exportSVG() {
|
|
const svgElement = document.getElementById('map');
|
|
if (!svgElement) return null;
|
|
return svgElement.outerHTML;
|
|
},
|
|
|
|
/**
|
|
* Export map as PNG
|
|
* @param {Number} width - Image width
|
|
* @param {Number} height - Image height
|
|
* @returns {Promise<Blob>} PNG blob
|
|
*/
|
|
async exportPNG(width = 2048, height = 2048) {
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
const svgElement = document.getElementById('map');
|
|
const svgString = new XMLSerializer().serializeToString(svgElement);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
const img = new Image();
|
|
const blob = new Blob([svgString], {type: 'image/svg+xml;charset=utf-8'});
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
img.onload = function() {
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
URL.revokeObjectURL(url);
|
|
canvas.toBlob((blob) => {
|
|
resolve(blob);
|
|
}, 'image/png');
|
|
};
|
|
|
|
img.onerror = reject;
|
|
img.src = url;
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Export specific data as JSON
|
|
* @param {String} key - Data key
|
|
* @returns {String} JSON string
|
|
*/
|
|
exportJSON(key) {
|
|
const data = key ? API.getData(key) : getMapState();
|
|
return JSON.stringify(data, null, 2);
|
|
},
|
|
|
|
// ------------------------------------------------------------------------
|
|
// EVENTS
|
|
// ------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Subscribe to events
|
|
* @param {String} event - Event name
|
|
* @param {Function} callback - Callback function
|
|
* @returns {Function} Unsubscribe function
|
|
*/
|
|
on(event, callback) {
|
|
return eventEmitter.on(event, callback);
|
|
},
|
|
|
|
/**
|
|
* Unsubscribe from events
|
|
* @param {String} event - Event name
|
|
* @param {Function} callback - Callback function
|
|
*/
|
|
off(event, callback) {
|
|
eventEmitter.off(event, callback);
|
|
},
|
|
|
|
/**
|
|
* Subscribe to event once
|
|
* @param {String} event - Event name
|
|
* @param {Function} callback - Callback function
|
|
*/
|
|
once(event, callback) {
|
|
eventEmitter.once(event, callback);
|
|
},
|
|
|
|
/**
|
|
* Emit custom event
|
|
* @param {String} event - Event name
|
|
* @param {*} data - Event data
|
|
*/
|
|
emit(event, data) {
|
|
eventEmitter.emit(event, data);
|
|
}
|
|
};
|
|
|
|
// ============================================================================
|
|
// HELPER FUNCTIONS
|
|
// ============================================================================
|
|
|
|
function getMapState() {
|
|
return {
|
|
seed: typeof seed !== 'undefined' ? seed : null,
|
|
mapId: typeof mapId !== 'undefined' ? mapId : null,
|
|
timestamp: Date.now(),
|
|
pack: pack ? {
|
|
cultures: pack.cultures || [],
|
|
states: pack.states || [],
|
|
burgs: pack.burgs || [],
|
|
rivers: pack.rivers || [],
|
|
religions: pack.religions || [],
|
|
provinces: pack.provinces || [],
|
|
markers: pack.markers || []
|
|
} : null,
|
|
grid: grid ? {
|
|
spacing: grid.spacing,
|
|
cellsX: grid.cellsX,
|
|
cellsY: grid.cellsY,
|
|
features: grid.features
|
|
} : null,
|
|
options: typeof options !== 'undefined' ? options : null
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// POSTMESSAGE BRIDGE
|
|
// ============================================================================
|
|
|
|
const PostMessageBridge = {
|
|
enabled: false,
|
|
targetOrigin: '*',
|
|
|
|
/**
|
|
* Enable PostMessage communication
|
|
* @param {String} origin - Target origin for postMessage (default: '*')
|
|
*/
|
|
enable(origin = '*') {
|
|
if (this.enabled) return;
|
|
|
|
this.targetOrigin = origin;
|
|
this.enabled = true;
|
|
|
|
// Listen for messages from parent window
|
|
window.addEventListener('message', this.handleMessage.bind(this));
|
|
|
|
// Forward all events to parent
|
|
this.setupEventForwarding();
|
|
|
|
console.log('[FMG API] PostMessage bridge enabled');
|
|
},
|
|
|
|
/**
|
|
* Disable PostMessage communication
|
|
*/
|
|
disable() {
|
|
this.enabled = false;
|
|
window.removeEventListener('message', this.handleMessage.bind(this));
|
|
console.log('[FMG API] PostMessage bridge disabled');
|
|
},
|
|
|
|
/**
|
|
* Handle incoming messages
|
|
*/
|
|
async handleMessage(event) {
|
|
if (!this.enabled) return;
|
|
|
|
const {type, payload, requestId} = event.data;
|
|
if (!type) return;
|
|
|
|
console.log('[FMG API] Received message:', type, payload);
|
|
|
|
try {
|
|
let result;
|
|
|
|
switch(type) {
|
|
// Map lifecycle
|
|
case 'CREATE_MAP':
|
|
result = await API.createMap(payload);
|
|
break;
|
|
case 'LOAD_MAP':
|
|
result = await API.loadMap(payload);
|
|
break;
|
|
case 'SAVE_MAP':
|
|
result = await API.saveMap(payload?.format);
|
|
break;
|
|
|
|
// Data access
|
|
case 'GET_STATE':
|
|
result = {success: true, data: API.getMapState()};
|
|
break;
|
|
case 'GET_DATA':
|
|
result = {success: true, data: API.getData(payload?.key)};
|
|
break;
|
|
case 'GET_RIVERS':
|
|
result = {success: true, data: API.getRivers()};
|
|
break;
|
|
case 'GET_CULTURES':
|
|
result = {success: true, data: API.getCultures()};
|
|
break;
|
|
case 'GET_STATES':
|
|
result = {success: true, data: API.getStates()};
|
|
break;
|
|
case 'GET_BURGS':
|
|
result = {success: true, data: API.getBurgs()};
|
|
break;
|
|
|
|
// Mutations
|
|
case 'UPDATE_RIVERS':
|
|
result = API.updateRivers(payload);
|
|
break;
|
|
case 'UPDATE_CULTURES':
|
|
result = API.updateCultures(payload);
|
|
break;
|
|
case 'UPDATE_STATES':
|
|
result = API.updateStates(payload);
|
|
break;
|
|
case 'UPDATE_BURGS':
|
|
result = API.updateBurgs(payload);
|
|
break;
|
|
case 'ADD_BURG':
|
|
result = API.addBurg(payload);
|
|
break;
|
|
|
|
// Export
|
|
case 'EXPORT_SVG':
|
|
result = {success: true, data: API.exportSVG()};
|
|
break;
|
|
case 'EXPORT_PNG':
|
|
const blob = await API.exportPNG(payload?.width, payload?.height);
|
|
const reader = new FileReader();
|
|
result = await new Promise((resolve) => {
|
|
reader.onload = () => resolve({success: true, data: reader.result});
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
break;
|
|
case 'EXPORT_JSON':
|
|
result = {success: true, data: API.exportJSON(payload?.key)};
|
|
break;
|
|
|
|
default:
|
|
result = {success: false, error: `Unknown message type: ${type}`};
|
|
}
|
|
|
|
// Send response
|
|
this.sendMessage('RESPONSE', result, requestId);
|
|
} catch (error) {
|
|
this.sendMessage('ERROR', {
|
|
success: false,
|
|
error: error.message
|
|
}, requestId);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Send message to parent window
|
|
*/
|
|
sendMessage(type, payload, requestId = null) {
|
|
if (!this.enabled) return;
|
|
if (window.parent === window) return; // Not in iframe
|
|
|
|
window.parent.postMessage({
|
|
type,
|
|
payload,
|
|
requestId,
|
|
timestamp: Date.now()
|
|
}, this.targetOrigin);
|
|
},
|
|
|
|
/**
|
|
* Forward API events to parent window
|
|
*/
|
|
setupEventForwarding() {
|
|
const events = [
|
|
'map:created',
|
|
'map:loaded',
|
|
'map:changed',
|
|
'rivers:updated',
|
|
'cultures:updated',
|
|
'states:updated',
|
|
'burgs:updated',
|
|
'burg:added'
|
|
];
|
|
|
|
events.forEach(event => {
|
|
API.on(event, (data) => {
|
|
this.sendMessage('EVENT', {event, data});
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
// ============================================================================
|
|
// INITIALIZATION
|
|
// ============================================================================
|
|
|
|
// Initialize on DOMContentLoaded
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initializeChangeDetection();
|
|
});
|
|
} else {
|
|
initializeChangeDetection();
|
|
}
|
|
|
|
// Auto-enable PostMessage if in iframe
|
|
if (window.self !== window.top) {
|
|
setTimeout(() => PostMessageBridge.enable(), 1000);
|
|
}
|
|
|
|
// ============================================================================
|
|
// EXPORT API
|
|
// ============================================================================
|
|
|
|
window.FMG_API = API;
|
|
window.FMG_PostMessageBridge = PostMessageBridge;
|
|
|
|
console.log('[FMG API] External API initialized. Access via window.FMG_API');
|
|
console.log('[FMG API] Available methods:', Object.keys(API));
|
|
|
|
})();
|