Embed KiCad's gerbview WASM in the graphics-to-WebGL blog post as a
click-to-load demo that opens the tiny_tapeout board entirely in-browser.
- GerberDemo.astro: lazy iframe embed + cross-origin-isolation reload guard
+ WebGL2/SharedArrayBuffer feature-detect with a poster fallback
- public/gerber-demo/{index.html,boot.js}: self-contained gerbview boot
harness (config seed, MEMFS board preload, argv auto-open)
- board/ tiny_tapeout layers + poster.png committed; wasm/ glue JS committed
(served same-origin — pthread worker can't be cross-origin), while
gerbview.wasm + kicad-resources.bin are git-ignored and served from
Cloudflare R2 (assets.pcbjam.com)
- COOP/COEP require-corp scoped to the post + /gerber-demo routes
(dev: astro.config.mjs + middleware.ts; prod: vercel.json)
- post converted to MDX (@astrojs/mdx) so it can embed the component
- scripts/: sync-demo-wasm.sh, r2-deploy.sh, r2-cors.json
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1920 lines
52 KiB
JavaScript
1920 lines
52 KiB
JavaScript
// Element Registry for E2E Tests
|
|
// Tracks all wxWindow instances with their positions for automated testing
|
|
(function() {
|
|
if (typeof window !== 'undefined' && typeof window.wxElementRegistry === 'undefined') {
|
|
window.wxElementRegistry = {
|
|
elements: new Map(),
|
|
version: 0,
|
|
|
|
register: function(id, info) {
|
|
this.elements.set(id, info);
|
|
this.version++;
|
|
},
|
|
|
|
update: function(id, updates) {
|
|
var elem = this.elements.get(id);
|
|
if (elem) {
|
|
Object.assign(elem, updates);
|
|
elem.lastUpdated = Date.now();
|
|
this.version++;
|
|
}
|
|
},
|
|
|
|
unregister: function(id) {
|
|
this.elements.delete(id);
|
|
this.version++;
|
|
},
|
|
|
|
findByLabel: function(label, options) {
|
|
options = options || {};
|
|
var results = [];
|
|
var exact = options.exact || false;
|
|
var visibleOnly = options.visible !== false;
|
|
|
|
this.elements.forEach(function(elem) {
|
|
if (visibleOnly && !elem.visible) return;
|
|
if (options.enabled && !elem.enabled) return;
|
|
if (options.type && elem.typeName !== options.type) return;
|
|
|
|
var matches = exact
|
|
? elem.label === label
|
|
: elem.label.indexOf(label) !== -1;
|
|
if (matches) results.push(elem);
|
|
});
|
|
|
|
return results;
|
|
},
|
|
|
|
findByName: function(name, options) {
|
|
options = options || {};
|
|
var results = [];
|
|
var exact = options.exact || false;
|
|
var visibleOnly = options.visible !== false;
|
|
|
|
this.elements.forEach(function(elem) {
|
|
if (visibleOnly && !elem.visible) return;
|
|
if (options.enabled && !elem.enabled) return;
|
|
if (options.type && elem.typeName !== options.type) return;
|
|
|
|
var matches = exact
|
|
? elem.name === name
|
|
: elem.name.indexOf(name) !== -1;
|
|
if (matches) results.push(elem);
|
|
});
|
|
|
|
return results;
|
|
},
|
|
|
|
findByType: function(typeName, options) {
|
|
options = options || {};
|
|
var results = [];
|
|
var visibleOnly = options.visible !== false;
|
|
|
|
this.elements.forEach(function(elem) {
|
|
if (visibleOnly && !elem.visible) return;
|
|
if (options.enabled && !elem.enabled) return;
|
|
if (elem.typeName === typeName) results.push(elem);
|
|
});
|
|
|
|
return results;
|
|
},
|
|
|
|
findAll: function(filter) {
|
|
filter = filter || {};
|
|
var results = [];
|
|
var visibleOnly = filter.visible !== false;
|
|
|
|
this.elements.forEach(function(elem) {
|
|
if (visibleOnly && !elem.visible) return;
|
|
if (filter.enabled && !elem.enabled) return;
|
|
if (filter.type && elem.typeName !== filter.type) return;
|
|
if (filter.label && elem.label.indexOf(filter.label) === -1) return;
|
|
if (filter.name && elem.name.indexOf(filter.name) === -1) return;
|
|
results.push(elem);
|
|
});
|
|
|
|
return results;
|
|
},
|
|
|
|
getElement: function(id) {
|
|
return this.elements.get(id) || null;
|
|
},
|
|
|
|
dump: function() {
|
|
console.log('[wxElementRegistry] Elements:', this.elements.size);
|
|
this.elements.forEach(function(elem) {
|
|
console.log(' ' + elem.id + ': ' + elem.typeName + ' "' + elem.label + '" at (' + elem.screenX + ',' + elem.screenY + ') ' + elem.width + 'x' + elem.height);
|
|
});
|
|
},
|
|
|
|
getStats: function() {
|
|
var stats = { total: 0, byType: {} };
|
|
this.elements.forEach(function(elem) {
|
|
stats.total++;
|
|
stats.byType[elem.typeName] = (stats.byType[elem.typeName] || 0) + 1;
|
|
});
|
|
return stats;
|
|
},
|
|
|
|
// ========== Rendered Elements (toolbar tools, menu items, etc.) ==========
|
|
renderedElements: new Map(),
|
|
renderedVersion: 0,
|
|
|
|
registerRendered: function(id, info) {
|
|
this.renderedElements.set(id, info);
|
|
this.renderedVersion++;
|
|
},
|
|
|
|
updateRendered: function(id, updates) {
|
|
var elem = this.renderedElements.get(id);
|
|
if (elem) {
|
|
Object.assign(elem, updates);
|
|
elem.lastUpdated = Date.now();
|
|
this.renderedVersion++;
|
|
}
|
|
},
|
|
|
|
unregisterRendered: function(id) {
|
|
this.renderedElements.delete(id);
|
|
this.renderedVersion++;
|
|
},
|
|
|
|
unregisterRenderedByParent: function(parentId) {
|
|
var toDelete = [];
|
|
var self = this;
|
|
this.renderedElements.forEach(function(elem, key) {
|
|
if (elem.parentId === parentId) {
|
|
toDelete.push(key);
|
|
}
|
|
});
|
|
toDelete.forEach(function(key) {
|
|
self.renderedElements.delete(key);
|
|
});
|
|
if (toDelete.length > 0) this.renderedVersion++;
|
|
},
|
|
|
|
findRenderedByLabel: function(label, options) {
|
|
options = options || {};
|
|
var results = [];
|
|
var exact = options.exact || false;
|
|
|
|
this.renderedElements.forEach(function(elem) {
|
|
if (options.enabled !== undefined && elem.enabled !== options.enabled) return;
|
|
if (options.elementType && elem.elementType !== options.elementType) return;
|
|
if (options.subType && elem.subType !== options.subType) return;
|
|
if (options.parentId && elem.parentId !== options.parentId) return;
|
|
|
|
var elemLabel = elem.label || elem.tooltip || '';
|
|
var matches = exact
|
|
? elemLabel === label
|
|
: elemLabel.indexOf(label) !== -1;
|
|
if (matches) results.push(elem);
|
|
});
|
|
|
|
return results;
|
|
},
|
|
|
|
findRenderedByType: function(elementType, options) {
|
|
options = options || {};
|
|
var results = [];
|
|
|
|
this.renderedElements.forEach(function(elem) {
|
|
if (elem.elementType !== elementType) return;
|
|
if (options.enabled !== undefined && elem.enabled !== options.enabled) return;
|
|
if (options.subType && elem.subType !== options.subType) return;
|
|
if (options.parentId && elem.parentId !== options.parentId) return;
|
|
results.push(elem);
|
|
});
|
|
|
|
return results;
|
|
},
|
|
|
|
findRenderedByParent: function(parentId, options) {
|
|
options = options || {};
|
|
var results = [];
|
|
|
|
this.renderedElements.forEach(function(elem) {
|
|
if (elem.parentId !== parentId) return;
|
|
if (options.enabled !== undefined && elem.enabled !== options.enabled) return;
|
|
if (options.elementType && elem.elementType !== options.elementType) return;
|
|
if (options.subType && elem.subType !== options.subType) return;
|
|
results.push(elem);
|
|
});
|
|
|
|
return results;
|
|
},
|
|
|
|
findAllRendered: function(filter) {
|
|
filter = filter || {};
|
|
var results = [];
|
|
|
|
this.renderedElements.forEach(function(elem) {
|
|
if (filter.enabled !== undefined && elem.enabled !== filter.enabled) return;
|
|
if (filter.elementType && elem.elementType !== filter.elementType) return;
|
|
if (filter.subType && elem.subType !== filter.subType) return;
|
|
if (filter.parentId && elem.parentId !== filter.parentId) return;
|
|
if (filter.label) {
|
|
var elemLabel = elem.label || elem.tooltip || '';
|
|
if (elemLabel.indexOf(filter.label) === -1) return;
|
|
}
|
|
results.push(elem);
|
|
});
|
|
|
|
return results;
|
|
},
|
|
|
|
dumpRendered: function() {
|
|
console.log('[wxElementRegistry] Rendered Elements:', this.renderedElements.size);
|
|
this.renderedElements.forEach(function(elem) {
|
|
console.log(' ' + elem.id + ': ' + elem.elementType + '/' + elem.subType + ' "' + (elem.label || elem.tooltip || '') + '" at (' + elem.screenX + ',' + elem.screenY + ') ' + elem.width + 'x' + elem.height);
|
|
});
|
|
},
|
|
|
|
getRenderedStats: function() {
|
|
var stats = { total: 0, byType: {} };
|
|
this.renderedElements.forEach(function(elem) {
|
|
stats.total++;
|
|
var key = elem.elementType + '/' + elem.subType;
|
|
stats.byType[key] = (stats.byType[key] || 0) + 1;
|
|
});
|
|
return stats;
|
|
}
|
|
};
|
|
}
|
|
})();
|
|
|
|
// Helper functions called from C++ via EM_ASM
|
|
function wxElementRegister(id, label, name, typeName, screenX, screenY, width, height, parentId, visible, enabled) {
|
|
if (window.wxElementRegistry) {
|
|
window.wxElementRegistry.register(id, {
|
|
id: id,
|
|
label: label,
|
|
name: name,
|
|
typeName: typeName,
|
|
screenX: screenX,
|
|
screenY: screenY,
|
|
width: width,
|
|
height: height,
|
|
centerX: screenX + Math.floor(width / 2),
|
|
centerY: screenY + Math.floor(height / 2),
|
|
parentId: parentId,
|
|
visible: visible,
|
|
enabled: enabled,
|
|
lastUpdated: Date.now()
|
|
});
|
|
}
|
|
}
|
|
|
|
function wxElementUpdate(id, label, name, typeName, screenX, screenY, width, height, parentId, visible, enabled) {
|
|
if (window.wxElementRegistry) {
|
|
var elem = window.wxElementRegistry.elements.get(id);
|
|
if (elem) {
|
|
elem.label = label;
|
|
elem.name = name;
|
|
elem.typeName = typeName;
|
|
elem.screenX = screenX;
|
|
elem.screenY = screenY;
|
|
elem.width = width;
|
|
elem.height = height;
|
|
elem.centerX = screenX + Math.floor(width / 2);
|
|
elem.centerY = screenY + Math.floor(height / 2);
|
|
elem.parentId = parentId;
|
|
elem.visible = visible;
|
|
elem.enabled = enabled;
|
|
elem.lastUpdated = Date.now();
|
|
window.wxElementRegistry.version++;
|
|
}
|
|
}
|
|
}
|
|
|
|
function wxElementUnregister(id) {
|
|
if (window.wxElementRegistry) {
|
|
window.wxElementRegistry.unregister(id);
|
|
}
|
|
}
|
|
|
|
// Helper functions for rendered elements (called from C++ via EM_ASM)
|
|
function wxRenderedElementRegister(id, parentId, elementType, subType, label, tooltip, screenX, screenY, width, height, enabled, index) {
|
|
if (window.wxElementRegistry) {
|
|
window.wxElementRegistry.registerRendered(id, {
|
|
id: id,
|
|
parentId: parentId,
|
|
elementType: elementType,
|
|
subType: subType,
|
|
label: label,
|
|
tooltip: tooltip,
|
|
screenX: screenX,
|
|
screenY: screenY,
|
|
width: width,
|
|
height: height,
|
|
centerX: screenX + Math.floor(width / 2),
|
|
centerY: screenY + Math.floor(height / 2),
|
|
enabled: enabled,
|
|
index: index,
|
|
lastUpdated: Date.now()
|
|
});
|
|
}
|
|
}
|
|
|
|
function wxRenderedElementUpdate(id, screenX, screenY, width, height, enabled) {
|
|
if (window.wxElementRegistry) {
|
|
window.wxElementRegistry.updateRendered(id, {
|
|
screenX: screenX,
|
|
screenY: screenY,
|
|
width: width,
|
|
height: height,
|
|
centerX: screenX + Math.floor(width / 2),
|
|
centerY: screenY + Math.floor(height / 2),
|
|
enabled: enabled
|
|
});
|
|
}
|
|
}
|
|
|
|
function wxRenderedElementUnregister(id) {
|
|
if (window.wxElementRegistry) {
|
|
window.wxElementRegistry.unregisterRendered(id);
|
|
}
|
|
}
|
|
|
|
function wxRenderedElementUnregisterByParent(parentId) {
|
|
if (window.wxElementRegistry) {
|
|
window.wxElementRegistry.unregisterRenderedByParent(parentId);
|
|
}
|
|
}
|
|
|
|
if (typeof navigator !== 'undefined') {
|
|
var browserInfo = (function () {
|
|
var ua = navigator.userAgent;
|
|
|
|
var match =
|
|
/(Opera)(?:.*version|)[ \/]([\w.]+)/.exec(ua) ||
|
|
/(OPR)[ \/]([\w.]+)/.exec(ua) ||
|
|
/(Edge)[ \/]([\w.]+)/.exec(ua) ||
|
|
/(MSIE) ([\w.]+)/.exec(ua) ||
|
|
/(Chrome)[ \/]([\w.]+)/.exec(ua) ||
|
|
/Version[ \/]([\w.]+) (Safari)/.exec(ua) ||
|
|
/(Safari)[ \/]([\w.]+)/.exec(ua) ||
|
|
/(Firefox)[ \/]([\w.]+)/.exec(ua) ||
|
|
ua.indexOf('compatible') < 0 &&
|
|
/(Mozilla)(?:.*? rv:([\w.]+)|)/.exec(ua) ||
|
|
[];
|
|
|
|
if (match[2] === 'Safari') {
|
|
return {
|
|
browser: match[2],
|
|
name: match[2],
|
|
version: match[1]
|
|
};
|
|
} else {
|
|
return {
|
|
browser: match[1] || '',
|
|
name: match[1] || '',
|
|
version: match[2] || '0'
|
|
};
|
|
}
|
|
})();
|
|
|
|
var isWebkit = function () {
|
|
return browserInfo.name === 'Chrome' || browserInfo.name === 'Safari';
|
|
}
|
|
|
|
var platformInfo = (function () {
|
|
var ua = navigator.userAgent;
|
|
|
|
var match =
|
|
/(Windows NT) ([\w.]+)/.exec(ua) ||
|
|
/(Mac OS X) ([\w.]+)/.exec(ua) ||
|
|
/(CrOS) \w+ ([\w.]+)/.exec(ua) ||
|
|
/(iPhone); .* OS ([\d_]+)/.exec(ua) ||
|
|
/(iPad); .* OS ([\d_]+)/.exec(ua);
|
|
|
|
var name = 'unknown';
|
|
var version = '';
|
|
|
|
if (match) {
|
|
name = match[1];
|
|
version = match[2];
|
|
} else {
|
|
var PLATFORMS = ['Android', 'iPhone', 'iPad', 'Windows', 'Macintosh', 'Linux', 'CrOs', 'NetBSD', 'OpenBSD', 'FreeBSD'];
|
|
|
|
for (var i = 0; i < PLATFORMS.length; i++) {
|
|
if (ua.indexOf(PLATFORMS[i]) !== -1) {
|
|
name = PLATFORMS[i];
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
name: name,
|
|
version: version
|
|
};
|
|
})();
|
|
}
|
|
|
|
var openUrl = function(url) {
|
|
if (typeof window !== 'undefined') {
|
|
window.open(url, '_blank');
|
|
}
|
|
};
|
|
|
|
var setIcon = function(id) {
|
|
var bitmap = bitmapMap.get(id);
|
|
|
|
var canvas = document.createElement('canvas');
|
|
var ctx = canvas.getContext('2d');
|
|
canvas.width = bitmap.width;
|
|
canvas.height = bitmap.height;
|
|
|
|
drawImage(ctx, bitmap, 0, 0);
|
|
|
|
var link = document.querySelector("link[rel*='icon']") || document.createElement('link');
|
|
link.type = 'image/png';
|
|
link.rel = 'shortcut icon';
|
|
link.href = canvas.toDataURL('image/png');
|
|
document.getElementsByTagName('head')[0].appendChild(link);
|
|
};
|
|
|
|
var displayScaleFactor = null;
|
|
|
|
var getDisplayScaleFactor = function () {
|
|
if (displayScaleFactor === null) {
|
|
displayScaleFactor = window.devicePixelRatio >= 1.5 ? 2.0 : 1.0;
|
|
}
|
|
return displayScaleFactor;
|
|
};
|
|
|
|
/* wxNonOwnedWindow */
|
|
|
|
// Ensure #window-container creates a stacking context so GL canvases
|
|
// render above the 2D #canvas inside #main-window.
|
|
// This runs at script eval, and with -pthread the same script also evaluates
|
|
// inside Web Workers, where `document` doesn't exist — guard or the workers
|
|
// die with "ReferenceError: document is not defined" before the app loads.
|
|
var windowContainer = (typeof document !== 'undefined')
|
|
? document.getElementById('window-container') : null;
|
|
if (windowContainer) {
|
|
windowContainer.style.position = 'relative';
|
|
windowContainer.style.zIndex = '1';
|
|
}
|
|
|
|
var nextWindowId = 0;
|
|
var windowMap = new Map();
|
|
|
|
var createWindow = function (id, needsCanvas, isVisible, classList) {
|
|
//console.log('createWindow: ' + id + ' ' + needsCanvas + ' ' + isVisible);
|
|
|
|
if (id === -1) {
|
|
id = nextWindowId++;
|
|
}
|
|
|
|
var window = null;
|
|
var canvas = null;
|
|
|
|
if (id === 0) {
|
|
window = document.getElementById('main-window');
|
|
canvas = document.getElementById('canvas');
|
|
} else {
|
|
window = document.createElement('div');
|
|
window.className = classList;
|
|
window.id = 'window-' + id;
|
|
window.style.display = isVisible ? 'block' : 'none';
|
|
|
|
if (needsCanvas) {
|
|
canvas = document.createElement('canvas');
|
|
canvas.className = 'window-canvas';
|
|
window.appendChild(canvas);
|
|
}
|
|
|
|
document.getElementById('window-container').appendChild(window);
|
|
}
|
|
|
|
windowMap.set(id, {
|
|
window: window,
|
|
canvas: canvas,
|
|
width: 0,
|
|
height: 0,
|
|
imageData: null,
|
|
context: null
|
|
});
|
|
|
|
return id;
|
|
};
|
|
|
|
var destroyWindow = function (id) {
|
|
var windowData = windowMap.get(id);
|
|
|
|
// The window element isn't always a child of #window-container (it may have
|
|
// been moved or never appended), so removeChild() on the container throws
|
|
// NotFoundError — which unwinds out of native callers like OpenProjectFiles
|
|
// and aborts the operation. Use Element.remove(): detaches from whatever
|
|
// parent it has, and is a no-op when unparented.
|
|
if (windowData && windowData.window) windowData.window.remove();
|
|
windowMap.delete(id);
|
|
};
|
|
|
|
// Read-only accessor for the DOM port's control layer (wx-dom.js): native
|
|
// controls attach to their top-level window's container element. No
|
|
// behavior change for the canvas port. (Guarded: with -pthread this script
|
|
// also evaluates in Web Workers, where `window` doesn't exist.)
|
|
if (typeof window !== 'undefined') {
|
|
window.__wxGetWindowElement = function (id) {
|
|
var windowData = windowMap.get(id);
|
|
return windowData ? windowData.window : null;
|
|
};
|
|
}
|
|
|
|
var setWindowVisibility = function (id, isVisible) {
|
|
//console.log('setWindowVisibility: ' + id + ': ' + isVisible);
|
|
|
|
var windowData = windowMap.get(id);
|
|
windowData.window.style.display = isVisible ? 'block' : 'none';
|
|
};
|
|
|
|
var setWindowRect = function (id, x, y, width, height) {
|
|
//console.log('setWindowRect: ' + id + ' (' + x + ', ' + y + ', ' + width + ', ' + height + ')');
|
|
|
|
var windowData = windowMap.get(id);
|
|
|
|
var header = document.getElementsByClassName('header')[0];
|
|
var headerHeight = header ? header.offsetHeight : 0;
|
|
|
|
var window = windowData.window;
|
|
window.style.left = x + 'px';
|
|
window.style.top = y + headerHeight + 'px';
|
|
window.style.width = width + 'px';
|
|
window.style.height = height + 'px';
|
|
|
|
var canvas = windowData.canvas;
|
|
|
|
if (canvas) {
|
|
var scaleFactor = getDisplayScaleFactor();
|
|
|
|
canvas.width = width * scaleFactor;
|
|
canvas.height = height * scaleFactor;
|
|
canvas.style.width = width + 'px';
|
|
canvas.style.height = height + 'px';
|
|
|
|
windowData.width = canvas.width;
|
|
windowData.height = canvas.height;
|
|
|
|
if (windowData.width > 0 && windowData.height > 0) {
|
|
windowData.imageData = new ImageData(windowData.width, windowData.height);
|
|
} else {
|
|
windowData.imageData = null;
|
|
}
|
|
|
|
var ctx = canvas.getContext('2d');
|
|
ctx.lineJoin = "round";
|
|
ctx.lineCap = "round";
|
|
ctx.imageSmoothingEnabled = false;
|
|
ctx.textBaseline = 'alphabetic';
|
|
ctx.depth = 0;
|
|
ctx.stack = [];
|
|
|
|
windowData.context = ctx;
|
|
}
|
|
};
|
|
|
|
var setWindowZIndex = function (id, zIndex) {
|
|
//console.log('setWindowZIndex: ' + id + ': ' + zIndex);
|
|
|
|
// The main window (id=0) lives outside #window-container at the body level.
|
|
// Setting its z-index would place it above #window-container's stacking context,
|
|
// hiding GL canvases and popup windows inside it.
|
|
if (id === 0) return;
|
|
|
|
var windowData = windowMap.get(id);
|
|
windowData.window.style.zIndex = zIndex;
|
|
};
|
|
|
|
var raiseWindow = function (id) {
|
|
var maxZ = 0;
|
|
|
|
// Check z-index of all windows
|
|
for (const windowId of windowMap.keys()) {
|
|
var windowData = windowMap.get(windowId);
|
|
if (windowId !== id && windowData) {
|
|
var style = document.defaultView.getComputedStyle(windowData.window);
|
|
var zIndex = parseInt(style.getPropertyValue('z-index'), 10);
|
|
if (!isNaN(zIndex)) {
|
|
maxZ = Math.max(maxZ, zIndex);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also check z-index of GL canvases so popups can appear above them
|
|
for (const [glId, canvas] of glCanvasMap.entries()) {
|
|
if (canvas && canvas.style.display !== 'none') {
|
|
var style = document.defaultView.getComputedStyle(canvas);
|
|
var zIndex = parseInt(style.getPropertyValue('z-index'), 10);
|
|
if (!isNaN(zIndex)) {
|
|
maxZ = Math.max(maxZ, zIndex);
|
|
}
|
|
}
|
|
}
|
|
|
|
setWindowZIndex(id, maxZ + 1);
|
|
};
|
|
|
|
var lowerWindow = function (id) {
|
|
var minZ = 0;
|
|
|
|
for (const windowId of windowMap.keys()) {
|
|
var windowData = windowMap.get(windowId);
|
|
if (windowId !== id && windowData) {
|
|
var style = document.defaultView.getComputedStyle(windowData.window);
|
|
var zIndex = parseInt(style.getPropertyValue('z-index'), 10);
|
|
if (!isNaN(zIndex)) {
|
|
minZ = Math.min(minZ, zIndex);
|
|
}
|
|
}
|
|
}
|
|
|
|
setWindowZIndex(id, minZ - 1);
|
|
};
|
|
|
|
/* wxColour */
|
|
|
|
var formatHexString = function (n) {
|
|
var hexString = n.toString(16);
|
|
while (hexString.length < 8) {
|
|
hexString = '0' + hexString;
|
|
}
|
|
return hexString;
|
|
};
|
|
|
|
var makeColorString = function (color) {
|
|
var a = (color >> 24) & 0xff;
|
|
var b = (color >> 16) & 0xff;
|
|
var g = (color >> 8) & 0xff;
|
|
var r = color & 0xff;
|
|
return 'rgba(' + r + ',' + g + ',' + b + ',' + a / 255.0 + ')';
|
|
//return '#' + formatHexString(color);
|
|
};
|
|
|
|
/* wxBitmap */
|
|
|
|
var nextBitmapId = 0;
|
|
var bitmapMap = new Map();
|
|
|
|
var createBitmap = function (x, y, width, height, data, scaleFactor) {
|
|
//console.log('setWindowImageData: ' + id + ': ' + '(' + x + ', ' + y + ') ' + width + 'x' + height);
|
|
|
|
var id = nextBitmapId++;
|
|
setBitmapData(id, x, y, width, height, data, scaleFactor);
|
|
|
|
return id;
|
|
};
|
|
|
|
var destroyBitmap = function (id) {
|
|
bitmapMap.delete(id);
|
|
};
|
|
|
|
var getBitmapData = function (id, data) {
|
|
var bitmap = bitmapMap.get(id);
|
|
|
|
var imageData;
|
|
|
|
if (bitmap.context) {
|
|
imageData = bitmap.context.getImageData(0, 0, bitmap.width, bitmap.height);
|
|
bitmap.context = null;
|
|
} else {
|
|
imageData = bitmap.imageData;
|
|
}
|
|
|
|
bitmap.imageBitmap = null;
|
|
|
|
Module.HEAPU8.set(imageData.data, data);
|
|
};
|
|
|
|
var setBitmapData = function (id, width, height, data, scaleFactor) {
|
|
var size = 4 * width * height;
|
|
var array = new Uint8ClampedArray(Module.HEAPU8.buffer, data, size);
|
|
var imageData = new ImageData(width, height);
|
|
imageData.data.set(array);
|
|
|
|
var bitmap = {
|
|
data: data,
|
|
size: size,
|
|
width: width,
|
|
height: height,
|
|
scaleFactor: scaleFactor,
|
|
imageData: imageData,
|
|
imageBitmap: null,
|
|
context: null
|
|
};
|
|
|
|
bitmapMap.set(id, bitmap);
|
|
|
|
createImageBitmap(imageData, 0, 0, width, height).then(function (imageBitmap) {
|
|
// TODO: fix race condition
|
|
var bitmap = bitmapMap.get(id);
|
|
if (bitmap && !bitmap.context) {
|
|
bitmap.imageBitmap = imageBitmap;
|
|
}
|
|
})
|
|
};
|
|
|
|
/* wxDC */
|
|
|
|
var nextContextId = 0;
|
|
var contextMap = new Map();
|
|
|
|
var createOffscreenContext = function (width, height) {
|
|
var canvas = null;
|
|
|
|
if (typeof OffscreenCanvas !== 'undefined') {
|
|
canvas = new OffscreenCanvas(width, height);
|
|
} else if (typeof document !== 'undefined' && 'createElement' in document) {
|
|
canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
}
|
|
|
|
if (canvas !== null) {
|
|
var ctx = canvas.getContext('2d');
|
|
ctx.lineJoin = "round";
|
|
ctx.lineCap = "round";
|
|
ctx.imageSmoothingEnabled = false;
|
|
ctx.textBaseline = 'alphabetic';
|
|
return ctx;
|
|
} else {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
var offscreenContext = createOffscreenContext(1, 1);
|
|
|
|
var pushContext = function (ctx) {
|
|
var saveCtx = {
|
|
x: ctx.x,
|
|
y: ctx.y,
|
|
width: ctx.width,
|
|
height: ctx.height,
|
|
scaleFactor: ctx.scaleFactor,
|
|
isInitialized: ctx.isInitialized
|
|
};
|
|
|
|
if (ctx.isInitialized) {
|
|
saveCtx.font = ctx.font,
|
|
saveCtx.lineWidth = ctx.lineWidth,
|
|
saveCtx.lineJoin = ctx.lineJoin,
|
|
saveCtx.lineCap = ctx.lineCap,
|
|
saveCtx.fillStyle = ctx.fillStyle,
|
|
saveCtx.strokeStyle = ctx.strokeStyle
|
|
saveCtx.dashCount = ctx.dashCount;
|
|
|
|
if (saveCtx.dashCount > 0) {
|
|
saveCtx.setLineDash(ctx.getLineDash());
|
|
}
|
|
|
|
ctx.restore();
|
|
ctx.save();
|
|
}
|
|
|
|
ctx.stack.push(saveCtx);
|
|
};
|
|
|
|
var popContext = function (ctx) {
|
|
var restoreCtx = ctx.stack.pop();
|
|
|
|
ctx.x = restoreCtx.x;
|
|
ctx.y = restoreCtx.y;
|
|
ctx.width = restoreCtx.width;
|
|
ctx.height = restoreCtx.height;
|
|
ctx.scaleFactor = restoreCtx.scaleFactor;
|
|
ctx.isInitialized = restoreCtx.isInitialized;
|
|
|
|
if (ctx.isInitialized) {
|
|
ctx.restore();
|
|
ctx.save();
|
|
|
|
ctx.font = restoreCtx.font;
|
|
ctx.lineWidth = restoreCtx.lineWidth;
|
|
ctx.lineJoin = restoreCtx.lineJoin;
|
|
ctx.lineCap = restoreCtx.lineCap;
|
|
ctx.fillStyle = restoreCtx.fillStyle;
|
|
ctx.strokeStyle = restoreCtx.strokeStyle;
|
|
ctx.dashCount = restoreCtx.dashCount;
|
|
|
|
if (ctx.dashCount > 0) {
|
|
ctx.setLineDash(restoreCtx.getLineDash());
|
|
}
|
|
|
|
// TODO: save/restore clip
|
|
ctx.beginPath();
|
|
ctx.rect(0, 0, ctx.width, ctx.height);
|
|
ctx.clip();
|
|
}
|
|
};
|
|
|
|
// GL canvas element management (for wxGLCanvas child windows)
|
|
var glCanvasMap = new Map();
|
|
var nextGLCanvasId = 1;
|
|
|
|
var createGLCanvas = function (isVisible) {
|
|
var id = nextGLCanvasId++;
|
|
var canvas = document.createElement('canvas');
|
|
canvas.id = 'glcanvas-' + id;
|
|
canvas.className = 'gl-canvas';
|
|
canvas.style.position = 'absolute';
|
|
canvas.style.display = 'none'; // Always start hidden until properly positioned
|
|
canvas.style.zIndex = '100'; // Above 2D canvas
|
|
canvas.style.pointerEvents = 'none'; // Don't intercept clicks - let main canvas handle events
|
|
document.getElementById('window-container').appendChild(canvas);
|
|
glCanvasMap.set(id, canvas);
|
|
return id;
|
|
};
|
|
|
|
var setGLCanvasRect = function (id, x, y, width, height) {
|
|
var canvas = glCanvasMap.get(id);
|
|
if (!canvas) return;
|
|
|
|
// Only position and show if we have valid dimensions
|
|
if (width <= 0 || height <= 0) {
|
|
canvas.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
var header = document.getElementsByClassName('header')[0];
|
|
var headerHeight = header ? header.offsetHeight : 0;
|
|
|
|
canvas.style.left = x + 'px';
|
|
canvas.style.top = (y + headerHeight) + 'px';
|
|
canvas.style.width = width + 'px';
|
|
canvas.style.height = height + 'px';
|
|
|
|
var scaleFactor = getDisplayScaleFactor();
|
|
canvas.width = width * scaleFactor;
|
|
canvas.height = height * scaleFactor;
|
|
|
|
// Show the canvas now that it's properly positioned
|
|
// (visibility is also controlled by setGLCanvasVisibility for show/hide logic)
|
|
if (canvas.dataset.shouldBeVisible !== 'false') {
|
|
canvas.style.display = 'block';
|
|
}
|
|
};
|
|
|
|
var setGLCanvasVisibility = function (id, isVisible) {
|
|
var canvas = glCanvasMap.get(id);
|
|
if (canvas) {
|
|
canvas.dataset.shouldBeVisible = isVisible ? 'true' : 'false';
|
|
canvas.style.display = isVisible ? 'block' : 'none';
|
|
}
|
|
};
|
|
|
|
var destroyGLCanvas = function (id) {
|
|
var canvas = glCanvasMap.get(id);
|
|
if (canvas && canvas.parentNode) {
|
|
canvas.parentNode.removeChild(canvas);
|
|
}
|
|
glCanvasMap.delete(id);
|
|
};
|
|
|
|
// Patch Emscripten's GL.newRenderingFrameStarted to handle contexts without temp buffers
|
|
// This is needed because wxGLCanvas creates additional WebGL contexts that don't have
|
|
// the temp buffers initialized (those are only set up during GLImmediate.init for the main context)
|
|
var patchGLNewRenderingFrameStarted = function () {
|
|
if (typeof GL === 'undefined' || !GL.newRenderingFrameStarted) {
|
|
return; // GL not initialized yet
|
|
}
|
|
if (GL._wxPatched) {
|
|
return; // Already patched
|
|
}
|
|
var originalNewRenderingFrameStarted = GL.newRenderingFrameStarted;
|
|
GL.newRenderingFrameStarted = function () {
|
|
if (!GL.currentContext) {
|
|
return;
|
|
}
|
|
// Skip temp buffer operations if they haven't been initialized for this context
|
|
if (!GL.currentContext.tempVertexBuffers1 || !GL.currentContext.tempVertexBufferCounters1) {
|
|
return;
|
|
}
|
|
return originalNewRenderingFrameStarted.call(this);
|
|
};
|
|
GL._wxPatched = true;
|
|
};
|
|
|
|
// Try to patch GL immediately and also set up a delayed check
|
|
// (GL object is created after wx.js runs)
|
|
if (typeof GL !== 'undefined') {
|
|
patchGLNewRenderingFrameStarted();
|
|
}
|
|
// Check periodically until patched (GL is created during Module initialization)
|
|
var glPatchInterval = setInterval(function () {
|
|
if (typeof GL !== 'undefined') {
|
|
patchGLNewRenderingFrameStarted();
|
|
if (GL._wxPatched) {
|
|
clearInterval(glPatchInterval);
|
|
}
|
|
}
|
|
}, 10);
|
|
// Clear interval after 5 seconds to avoid memory leak if GL never gets created
|
|
setTimeout(function () {
|
|
clearInterval(glPatchInterval);
|
|
}, 5000);
|
|
|
|
var createWindowContext = function (windowId, x, y, width, height, scaleFactor) {
|
|
var id = nextContextId++;
|
|
//console.log('createWindowContext: ' + windowId + ' ' + x + ' ' + y + ' ' + width + ' ' + height);
|
|
|
|
var windowData = windowMap.get(windowId);
|
|
var ctx = windowData.context;
|
|
|
|
if (ctx.depth > 0) {
|
|
pushContext(ctx);
|
|
}
|
|
|
|
ctx.x = x;
|
|
ctx.y = y;
|
|
ctx.width = width;
|
|
ctx.height = height;
|
|
ctx.scaleFactor = scaleFactor;
|
|
ctx.isInitialized = false;
|
|
ctx.depth++;
|
|
|
|
contextMap.set(id, ctx);
|
|
|
|
return id;
|
|
};
|
|
|
|
var destroyWindowContext = function (id) {
|
|
var ctx = contextMap.get(id);
|
|
|
|
if (ctx.isInitialized) {
|
|
ctx.restore();
|
|
}
|
|
|
|
if (ctx.depth > 1) {
|
|
popContext(ctx);
|
|
}
|
|
|
|
ctx.depth--;
|
|
|
|
//console.log('destroyContext: ' + id + ' ' + ctx.width + ' ' + ctx.height);
|
|
contextMap.delete(id);
|
|
};
|
|
|
|
var createMemoryContext = function (bitmapId, scaleFactor) {
|
|
var contextId = nextContextId++;
|
|
var bitmap = bitmapMap.get(bitmapId);
|
|
|
|
var ctx = createOffscreenContext(bitmap.width, bitmap.height);
|
|
|
|
ctx.x = 0;
|
|
ctx.y = 0;
|
|
ctx.width = bitmap.width / scaleFactor;
|
|
ctx.height = bitmap.height / scaleFactor;
|
|
ctx.scaleFactor = scaleFactor;
|
|
ctx.dashCount = 0;
|
|
ctx.isInitialized = true;
|
|
ctx.depth = 0;
|
|
ctx.stack = [];
|
|
|
|
ctx.scale(scaleFactor, scaleFactor);
|
|
|
|
contextMap.set(contextId, ctx);
|
|
|
|
drawImage(ctx, bitmap, 0, 0);
|
|
|
|
bitmap.imageData = null;
|
|
bitmap.imageBitmap = null;
|
|
bitmap.context = ctx;
|
|
|
|
return contextId;
|
|
};
|
|
|
|
var destroyMemoryContext = function (contextId) {
|
|
//console.log('deselectBitmap: ' + contextId);
|
|
contextMap.delete(contextId);
|
|
};
|
|
|
|
var getContext = function (id) {
|
|
var ctx = contextMap.get(id);
|
|
|
|
if (!ctx.isInitialized) {
|
|
// scale and translate(x, y)
|
|
var x = ctx.x;
|
|
var y = ctx.y;
|
|
var scaleFactor = ctx.scaleFactor;
|
|
|
|
ctx.setTransform(scaleFactor, 0, 0, scaleFactor, scaleFactor * x, scaleFactor * y);
|
|
|
|
ctx.save();
|
|
|
|
ctx.beginPath();
|
|
ctx.rect(0, 0, ctx.width, ctx.height);
|
|
ctx.clip()
|
|
|
|
ctx.dashCount = 0;
|
|
ctx.isInitialized = true;
|
|
}
|
|
|
|
return ctx;
|
|
};
|
|
|
|
var setFont = function (id, font) {
|
|
var ctx = getContext(id);
|
|
ctx.font = font;
|
|
};
|
|
|
|
var createPattern = function (contextId, bitmapId) {
|
|
var ctx = getContext(contextId);
|
|
var bitmap = bitmapMap.get(bitmapId);
|
|
var source;
|
|
|
|
if (bitmap.imageBitmap) {
|
|
source = bitmap.imageBitmap;
|
|
} else if (bitmap.context) {
|
|
source = bitmap.context.canvas;
|
|
} else {
|
|
offscreenContext.canvas.width = bitmap.width;
|
|
offscreenContext.canvas.height = bitmap.height;
|
|
offscreenContext.putImageData(bitmap.imageData, 0, 0);
|
|
source = offscreenContext.canvas;
|
|
}
|
|
|
|
return ctx.createPattern(source, 'repeat');
|
|
};
|
|
|
|
var setBrush = function (contextId, color, bitmapId) {
|
|
var ctx = getContext(contextId);
|
|
|
|
if (bitmapId === -1 || typeof bitmapId === 'undefined') {
|
|
ctx.fillStyle = makeColorString(color);
|
|
} else {
|
|
ctx.fillStyle = createPattern(contextId, bitmapId);
|
|
}
|
|
};
|
|
|
|
var lineJoinMap = [
|
|
'round',
|
|
'bevel',
|
|
'miter'
|
|
];
|
|
|
|
var lineCapMap = [
|
|
'butt',
|
|
'round',
|
|
'square'
|
|
];
|
|
|
|
var setPen = function (contextId, color, lineWidth, lineJoin, lineCap, dashCount, dashPtr, bitmapId) {
|
|
var ctx = getContext(contextId);
|
|
|
|
ctx.lineWidth = lineWidth;
|
|
ctx.lineJoin = lineJoinMap[lineJoin];
|
|
ctx.lineCap = lineCapMap[lineCap];
|
|
|
|
if (bitmapId === -1 || typeof bitmapId === 'undefined') {
|
|
ctx.strokeStyle = makeColorString(color);
|
|
} else {
|
|
ctx.strokeStyle = createPattern(contextId, bitmapId);
|
|
}
|
|
|
|
ctx.dashCount = dashCount;
|
|
var dashes = [];
|
|
for (var i = 0; i < dashCount; i++) {
|
|
dashes.push(Module.HEAP8[dashPtr + i]);
|
|
}
|
|
ctx.setLineDash(dashes);
|
|
};
|
|
|
|
var resetClip = function (ctx) {
|
|
var font = ctx.font;
|
|
var lineWidth = ctx.lineWidth;
|
|
var lineJoin = ctx.lineJoin;
|
|
var lineCap = ctx.lineCap;
|
|
var fillStyle = ctx.fillStyle;
|
|
var strokeStyle = ctx.strokeStyle;
|
|
|
|
ctx.restore();
|
|
ctx.save();
|
|
|
|
ctx.font = font;
|
|
ctx.lineWidth = lineWidth;
|
|
ctx.lineJoin = lineJoin;
|
|
ctx.lineCap = lineCap;
|
|
ctx.fillStyle = fillStyle;
|
|
ctx.strokeStyle = strokeStyle;
|
|
};
|
|
|
|
var clipRect = function (id, x, y, width, height) {
|
|
//console.log('clipRect: ' + x + ' ' + y + ' ' + width + ' ' + height);
|
|
var ctx = getContext(id);
|
|
|
|
// FIX: If clip region is empty (0,0,0,0), use full context dimensions
|
|
// This happens when wxWidgets hasn't properly initialized the clip region
|
|
if (width <= 0 || height <= 0) {
|
|
x = 0;
|
|
y = 0;
|
|
width = ctx.width;
|
|
height = ctx.height;
|
|
}
|
|
|
|
resetClip(ctx);
|
|
|
|
ctx.beginPath();
|
|
ctx.rect(x, y, width, height);
|
|
ctx.clip();
|
|
};
|
|
|
|
// Clip to a non-rectangular region composed of multiple rectangles
|
|
var clipRegion = function (id, rectDataPtr, rectCount) {
|
|
var ctx = getContext(id);
|
|
resetClip(ctx);
|
|
|
|
ctx.beginPath();
|
|
|
|
// Read rectangle data from WASM memory (4 ints per rect: x, y, w, h)
|
|
for (var i = 0; i < rectCount; i++) {
|
|
var offset = rectDataPtr / 4 + i * 4; // Convert byte offset to int offset
|
|
var x = Module.HEAP32[offset];
|
|
var y = Module.HEAP32[offset + 1];
|
|
var w = Module.HEAP32[offset + 2];
|
|
var h = Module.HEAP32[offset + 3];
|
|
ctx.rect(x, y, w, h);
|
|
}
|
|
|
|
ctx.clip();
|
|
};
|
|
|
|
var destroyClip = function (id) {
|
|
var ctx = getContext(id);
|
|
|
|
resetClip(ctx);
|
|
|
|
ctx.beginPath();
|
|
ctx.rect(0, 0, ctx.width, ctx.height);
|
|
ctx.clip();
|
|
};
|
|
|
|
var clearRect = function (id, width, height, color) {
|
|
var ctx = getContext(id);
|
|
|
|
var saveFillStyle = ctx.fillStyle;
|
|
ctx.fillStyle = makeColorString(color);
|
|
// TODO: save/restore clip
|
|
|
|
ctx.fillRect(0, 0, width, height);
|
|
ctx.fillStyle = saveFillStyle;
|
|
};
|
|
|
|
var drawRect = function (id, x, y, width, height, fill, stroke) {
|
|
var ctx = getContext(id);
|
|
|
|
if (fill) {
|
|
ctx.fillRect(x, y, width, height);
|
|
}
|
|
|
|
if (stroke) {
|
|
ctx.strokeRect(x, y, width, height);
|
|
}
|
|
};
|
|
|
|
var drawRoundedRect = function (id, x, y, width, height, radius, fill, stroke) {
|
|
var ctx = getContext(id);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + radius, y);
|
|
ctx.lineTo(x + width - radius, y);
|
|
ctx.arcTo(x + width, y, x + width, y + radius, radius);
|
|
ctx.lineTo(x + width, y + height - radius);
|
|
ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
|
|
ctx.lineTo(x + radius, y + height);
|
|
ctx.arcTo(x, y + height, x, y + height - radius, radius);
|
|
ctx.lineTo(x, y + radius);
|
|
ctx.arcTo(x, y, x + radius, y, radius);
|
|
ctx.closePath();
|
|
|
|
if (fill) {
|
|
ctx.fill();
|
|
}
|
|
|
|
if (stroke) {
|
|
ctx.stroke();
|
|
}
|
|
};
|
|
|
|
var drawEllipse = function (id, x, y, width, height, fill, stroke) {
|
|
var ctx = getContext(id);
|
|
|
|
var radiusX = width / 2.0;
|
|
var radiusY = height / 2.0;
|
|
var cx = x + radiusX;
|
|
var cy = y + radiusY
|
|
|
|
ctx.beginPath();
|
|
ctx.ellipse(cx, cy, radiusX, radiusY, 0.0, 0.0, 2 * Math.PI);
|
|
|
|
if (fill) {
|
|
ctx.fill();
|
|
}
|
|
|
|
if (stroke) {
|
|
ctx.stroke();
|
|
}
|
|
};
|
|
|
|
var drawArc = function (id, x, y, radius, startAngle, endAngle, fill, stroke) {
|
|
var ctx = getContext(id);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, y);
|
|
ctx.arc(x, y, radius, startAngle, endAngle, true);
|
|
|
|
if (fill) {
|
|
ctx.fill();
|
|
}
|
|
|
|
if (stroke) {
|
|
ctx.stroke();
|
|
}
|
|
};
|
|
|
|
var drawEllipticArc = function (id, x, y, width, height, startDegrees, endDegrees, fill, stroke) {
|
|
var ctx = getContext(id);
|
|
|
|
var radiusX = width / 2.0;
|
|
var radiusY = height / 2.0;
|
|
var cx = x + radiusX;
|
|
var cy = y + radiusY;
|
|
var startRadians = -startDegrees * (Math.PI / 180.0);
|
|
var endRadians = -endDegrees * (Math.PI / 180.0);
|
|
|
|
if (fill) {
|
|
ctx.beginPath();
|
|
ctx.ellipse(cx, cy, radiusX, radiusY, 0.0, startRadians, endRadians, true);
|
|
ctx.lineTo(cx, cy);
|
|
ctx.fill();
|
|
}
|
|
|
|
if (stroke) {
|
|
ctx.beginPath();
|
|
ctx.ellipse(cx, cy, radiusX, radiusY, 0.0, startRadians, endRadians, true);
|
|
ctx.stroke();
|
|
}
|
|
};
|
|
|
|
var drawPoint = function (id, x, y) {
|
|
var ctx = getContext(id);
|
|
ctx.strokeRect(x, y, 1e-6, 1e-6);
|
|
};
|
|
|
|
var drawLine = function (id, x1, y1, x2, y2) {
|
|
var ctx = getContext(id);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1, y1);
|
|
ctx.lineTo(x2, y2);
|
|
|
|
ctx.stroke();
|
|
};
|
|
|
|
var drawLines = function (id, n, ptr) {
|
|
var ctx = getContext(id);
|
|
|
|
if (n > 0) {
|
|
var index = ptr >> 2;
|
|
var x = Module.HEAP32[index++];
|
|
var y = Module.HEAP32[index++];
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, y);
|
|
|
|
for (var i = 1; i < n; i++) {
|
|
x = Module.HEAP32[index++];
|
|
y = Module.HEAP32[index++];
|
|
ctx.lineTo(x, y);
|
|
}
|
|
|
|
ctx.stroke();
|
|
}
|
|
};
|
|
|
|
var drawPolygon = function (id, n, ptr, fillEvenOdd, fill, stroke) {
|
|
var ctx = getContext(id);
|
|
|
|
if (n > 0) {
|
|
var index = ptr >> 2;
|
|
var x = Module.HEAP32[index++];
|
|
var y = Module.HEAP32[index++];
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, y);
|
|
|
|
for (var i = 1; i < n; i++) {
|
|
x = Module.HEAP32[index++];
|
|
y = Module.HEAP32[index++];
|
|
ctx.lineTo(x, y);
|
|
}
|
|
|
|
ctx.closePath();
|
|
|
|
if (fill) {
|
|
ctx.fill(fillEvenOdd ? 'evenodd' : 'nonzero');
|
|
}
|
|
|
|
if (stroke) {
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
};
|
|
|
|
var drawImage = function (ctx, bitmap, x, y) {
|
|
var w = bitmap.width;
|
|
var h = bitmap.height;
|
|
var sf = bitmap.scaleFactor;
|
|
var source;
|
|
|
|
// console.log('drawImage: ' + bitmap.id + ' ' + x + ' ' + y + ' ' + w + ' ' + h + ' ' + sf);
|
|
|
|
if (bitmap.imageBitmap) {
|
|
source = bitmap.imageBitmap;
|
|
} else if (bitmap.context) {
|
|
source = bitmap.context.canvas;
|
|
} else {
|
|
offscreenContext.canvas.width = bitmap.width;
|
|
offscreenContext.canvas.height = bitmap.height;
|
|
offscreenContext.putImageData(bitmap.imageData, 0, 0);
|
|
source = offscreenContext.canvas;
|
|
}
|
|
|
|
if (bitmap.scaleFactor == 1.0) {
|
|
ctx.drawImage(source, x, y);
|
|
} else {
|
|
var sf = 1.0 / bitmap.scaleFactor;
|
|
ctx.drawImage(source, 0, 0, w, h, x, y, w * sf, h * sf);
|
|
}
|
|
};
|
|
|
|
var drawBitmap = function (contextId, bitmapId, x, y) {
|
|
var ctx = getContext(contextId);
|
|
var bitmap = bitmapMap.get(bitmapId);
|
|
|
|
//console.log('drawBitmap: ' + contextId + ' ' + bitmapId + ' (' + x + ', ' + y + ')' + ' (' + bitmap.width + ', ' + bitmap.height + ')');
|
|
|
|
drawImage(ctx, bitmap, x, y);
|
|
};
|
|
|
|
var blit = function (srcId, dstId, sx, sy, width, height, dx, dy) {
|
|
var srcCtx = getContext(srcId);
|
|
var dstCtx = getContext(dstId);
|
|
|
|
//console.log('blit: ' + sx + ' ' + sy + ' ' + dx + ' ' + dy + ' ' + width + ' ' + height + ' ' + srcCtx.scaleFactor + ' ' + dstCtx.scaleFactor);
|
|
|
|
var sf = srcCtx.scaleFactor
|
|
dstCtx.drawImage(srcCtx.canvas, sx * sf, sy * sf, width * sf, height * sf, dx, dy, width, height);
|
|
};
|
|
|
|
var drawText = function (id, text, x, y, textColor, underline, strikethrough) {
|
|
var ctx = getContext(id);
|
|
//console.log('drawText: ' + text + ' ' + id + ' ' + ctx.width + ' ' + ctx.height);
|
|
|
|
var fillStyle = ctx.fillStyle;
|
|
|
|
ctx.fillStyle = makeColorString(textColor);
|
|
ctx.fillText(text, x, y);
|
|
|
|
// Draw text decorations (underline and/or strikethrough)
|
|
if (underline || strikethrough) {
|
|
var metrics = ctx.measureText(text);
|
|
var textWidth = metrics.width;
|
|
|
|
// Save current state
|
|
var strokeStyle = ctx.strokeStyle;
|
|
var lineWidth = ctx.lineWidth;
|
|
|
|
ctx.strokeStyle = makeColorString(textColor);
|
|
ctx.lineWidth = 1;
|
|
|
|
if (underline) {
|
|
// Draw underline below the baseline
|
|
// Use fontBoundingBoxDescent if available, otherwise estimate
|
|
var descent = metrics.fontBoundingBoxDescent || 3;
|
|
var underlineY = y + descent;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, underlineY);
|
|
ctx.lineTo(x + textWidth, underlineY);
|
|
ctx.stroke();
|
|
}
|
|
|
|
if (strikethrough) {
|
|
// Draw strikethrough at middle of text
|
|
// Use fontBoundingBoxAscent if available, otherwise estimate
|
|
var ascent = metrics.fontBoundingBoxAscent || 10;
|
|
var strikeY = y - ascent * 0.35; // ~35% up from baseline
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, strikeY);
|
|
ctx.lineTo(x + textWidth, strikeY);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Restore state
|
|
ctx.strokeStyle = strokeStyle;
|
|
ctx.lineWidth = lineWidth;
|
|
}
|
|
|
|
ctx.fillStyle = fillStyle;
|
|
};
|
|
|
|
var measureText = function (text, font) {
|
|
offscreenContext.font = font;
|
|
|
|
var textMetrics = offscreenContext.measureText(text);
|
|
return Math.round(textMetrics.width);
|
|
};
|
|
|
|
var rotateAtPoint = function (id, x, y, angle) {
|
|
var ctx = getContext(id);
|
|
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.rotate(-angle * (Math.PI / 180.0));
|
|
};
|
|
|
|
var clearRotation = function (id) {
|
|
var ctx = getContext(id);
|
|
ctx.restore();
|
|
};
|
|
|
|
/* wxCursor */
|
|
|
|
var cursorMap = [
|
|
'default',
|
|
'crosshair',
|
|
'hand',
|
|
'text',
|
|
'wait',
|
|
'help',
|
|
'e-resize',
|
|
'n-resize',
|
|
'ne-resize',
|
|
'nw-resize',
|
|
's-resize',
|
|
'se-resize',
|
|
'sw-resize',
|
|
'w-resize',
|
|
'ns-resize',
|
|
'ew-resize',
|
|
'nesw-resize',
|
|
'nwse-resize',
|
|
'col-resize',
|
|
'row-resize',
|
|
'move',
|
|
'vertical-text',
|
|
'cell',
|
|
'context-menu',
|
|
'alias',
|
|
'progress',
|
|
'no-drop',
|
|
'copy',
|
|
'none',
|
|
'not-allowed',
|
|
'zoom-in',
|
|
'zoom-out',
|
|
'grab',
|
|
'grabbing'
|
|
];
|
|
|
|
var setCursor = function (cursorIndex, bitmapId, hotSpotX, hotSpotY) {
|
|
if (cursorIndex >= 0 && cursorIndex < cursorMap.length) {
|
|
var cursor = cursorMap[cursorIndex];
|
|
if (cursor.startsWith('grab') && isWebkit()) {
|
|
cursor = '-webkit-' + cursor;
|
|
}
|
|
Module.canvas.style.cursor = cursor;
|
|
} else {
|
|
var bitmap = bitmapMap.get(bitmapId);
|
|
|
|
var canvas = document.createElement('canvas');
|
|
var ctx = canvas.getContext('2d');
|
|
canvas.width = bitmap.width;
|
|
canvas.height = bitmap.height;
|
|
|
|
drawImage(ctx, bitmap, 0, 0);
|
|
var dataUrl = 'url(' + canvas.toDataURL('image/png') + ')';
|
|
|
|
Module.canvas.style.cursor = dataUrl + ' ' + hotSpotX + ' ' + hotSpotY + ', auto';
|
|
}
|
|
};
|
|
|
|
var showFullscreen = function (enable) {
|
|
if (enable) {
|
|
if (document.body.requestFullscreen) {
|
|
document.body.requestFullscreen();
|
|
} else if (document.body.webkitRequestFullscreen()) {
|
|
document.body.webkitRequestFullscreen();
|
|
}
|
|
} else {
|
|
if (document.exitFullscreen) {
|
|
document.exitFullscreen();
|
|
} else if (document.webkitExitFullscreen) {
|
|
document.webkitExitFullscreen();
|
|
}
|
|
}
|
|
};
|
|
|
|
var showFileDialog = function (multiple) {
|
|
var input = document.createElement('input');
|
|
if (multiple) {
|
|
input.setAttribute('multiple', '');
|
|
}
|
|
input.type = 'file';
|
|
input.onchange = function () {
|
|
for (var i = 0; i < input.files.length; i++) {
|
|
var file = input.files[i];
|
|
console.log('file selected: ' + file.name);
|
|
file.arrayBuffer().then(function (arrayBuffer) {
|
|
var array = new Uint8Array(arrayBuffer);
|
|
var path = '/tmp/' + file.name;
|
|
|
|
var stream = FS.open(path, 'w+');
|
|
var retCode = 0;
|
|
|
|
if (stream) {
|
|
FS.write(stream, array, 0, file.size);
|
|
FS.close(stream);
|
|
} else {
|
|
retCode = 1;
|
|
}
|
|
|
|
ccall('OpenFileCallback', 'void', ['string', 'number'], [path, retCode]);
|
|
});
|
|
}
|
|
};
|
|
input.click();
|
|
};
|
|
|
|
var downloadFile = function (filename, size, data) {
|
|
var link = document.createElement('a');
|
|
|
|
var sharedArray = new Uint8Array(Module.HEAPU8.buffer, data, size);
|
|
// Blob fails when passed SharedArrayBuffer
|
|
var array = new Uint8Array(sharedArray);
|
|
var blob = new Blob([array], {type: 'application/octet-stream'});
|
|
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = filename;
|
|
link.click();
|
|
};
|
|
|
|
var endModal = null;
|
|
/*
|
|
var startModal = async function () {
|
|
Asyncify.handleAsync(async () => {
|
|
console.log('startModal');
|
|
const result = await new Promise((resolve, reject) => {
|
|
endModal = resolve;
|
|
});
|
|
console.log('modal result: ' + result);
|
|
});
|
|
};
|
|
*/
|
|
|
|
/* wxLocalStorageConfig */
|
|
|
|
var hasConfigEntry = function (key) {
|
|
try {
|
|
return localStorage.getItem(key) !== null;
|
|
} catch (error) {
|
|
console.error(error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
var hasConfigGroup = function (key) {
|
|
try {
|
|
for (var i = 0; i < localStorage.length; i++) {
|
|
if (localStorage.key(i).startsWith(key)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
} catch (error) {
|
|
console.error(error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
var getConfigEntryCount = function (prefix, recurse) {
|
|
var entryCount = 0;
|
|
|
|
try {
|
|
for (var i = 0; i < localStorage.length; i++) {
|
|
var key = localStorage.key(i);
|
|
if (key.startsWith(prefix)) {
|
|
var end = key.indexOf('/', prefix.length);
|
|
if (end == -1 || recurse) {
|
|
++entryCount;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
return entryCount;
|
|
};
|
|
|
|
var getConfigEntryIndex = function (prefix, index) {
|
|
var entryCount = 0;
|
|
|
|
try {
|
|
for (var i = 0; i < localStorage.length; i++) {
|
|
var key = localStorage.key(i);
|
|
if (key.startsWith(prefix)) {
|
|
var end = key.indexOf('/', prefix.length);
|
|
if (end == -1) {
|
|
if (entryCount >= index) {
|
|
return i;
|
|
} else {
|
|
++entryCount;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
return -1;
|
|
};
|
|
|
|
var getConfigGroupCount = function (prefix, recurse) {
|
|
var children = new Set();
|
|
|
|
try {
|
|
for (var i = 0; i < localStorage.length; i++) {
|
|
var key = localStorage.key(i);
|
|
if (key.startsWith(prefix)) {
|
|
var end = key.indexOf('/', prefix.length);
|
|
if (end != -1) {
|
|
if (recurse) {
|
|
end = key.lastIndexOf('/');
|
|
}
|
|
var child = key.substring(prefix.length, end);
|
|
if (!children.has(child)) {
|
|
children.add(child);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
return children.size;
|
|
};
|
|
|
|
var getConfigGroupIndex = function (prefix, index) {
|
|
var children = new Set();
|
|
|
|
try {
|
|
for (var i = 0; i < localStorage.length; i++) {
|
|
var key = localStorage.key(i);
|
|
if (key.startsWith(prefix)) {
|
|
var end = key.indexOf('/', prefix.length);
|
|
if (end != -1) {
|
|
var child = key.substring(prefix.length, end);
|
|
if (!children.has(child)) {
|
|
if (children.size >= index) {
|
|
return i;
|
|
} else {
|
|
children.add(child);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
return -1;
|
|
};
|
|
|
|
var getConfigKeyLength = function (index) {
|
|
try {
|
|
return localStorage.key(index).length;
|
|
} catch (error) {
|
|
console.error(error);
|
|
return 0;
|
|
}
|
|
};
|
|
|
|
var getConfigKey = function (index, keyBuffer, length) {
|
|
try {
|
|
var key = localStorage.key(index);
|
|
stringToUTF8(key, keyBuffer, length);
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
};
|
|
|
|
var getConfigEntryLength = function (key) {
|
|
var value = null;
|
|
try {
|
|
value = localStorage.getItem(key);
|
|
} catch (error) {
|
|
//console.error(error);
|
|
}
|
|
|
|
if (value === null) {
|
|
return -1;
|
|
} else {
|
|
return value.length
|
|
}
|
|
};
|
|
|
|
var getConfigEntry = function (key, valueBuffer, length) {
|
|
try {
|
|
var value = localStorage.getItem(key);
|
|
if (value !== null) {
|
|
stringToUTF8(value, valueBuffer, length);
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
var setConfigEntry = function (key, value) {
|
|
try {
|
|
localStorage.setItem(key, value);
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
};
|
|
|
|
var removeConfigEntry = function (key) {
|
|
try {
|
|
localStorage.removeItem(key);
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
};
|
|
|
|
var removeConfigGroup = function (group) {
|
|
try {
|
|
var keysToRemove = [];
|
|
|
|
for (var i = 0; i < localStorage.length; i++) {
|
|
var key = localStorage.key(i);
|
|
if (key.startsWith(group)) {
|
|
keysToRemove.push(key);
|
|
}
|
|
}
|
|
for (var i = 0; i < keysToRemove.length; i++) {
|
|
localStorage.removeItem(keysToRemove[i]);
|
|
}
|
|
return keysToRemove.length > 0;
|
|
} catch (error) {
|
|
console.error(error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
var clearConfig = function () {
|
|
try {
|
|
localStorage.clear();
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
};
|
|
|
|
var renameConfigGroup = function (oldGroup, newGroup) {
|
|
try {
|
|
var keysToRename = [];
|
|
|
|
for (var i = 0; i < localStorage.length; i++) {
|
|
var key = localStorage.key(i);
|
|
if (key.startsWith(oldGroup)) {
|
|
keysToRename.push(key);
|
|
} else if (key.startsWith(newGroup)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (keysToRename.length > 0) {
|
|
for (var i = 0; i < keysToRename.length; i++) {
|
|
var oldKey = keysToRename[i];
|
|
var newKey = newGroup + oldKey.substring(oldGroup.length);
|
|
|
|
var value = localStorage.getItem(oldKey);
|
|
localStorage.setItem(newKey, value);
|
|
localStorage.removeItem(oldKey);
|
|
}
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
return false;
|
|
}
|
|
|
|
};
|
|
|
|
/* HTML5 Drag and Drop Support */
|
|
|
|
var pendingDropFiles = [];
|
|
var pendingDropX = 0;
|
|
var pendingDropY = 0;
|
|
|
|
var registerDragDropHandlers = function () {
|
|
var canvas = Module.canvas;
|
|
if (!canvas) {
|
|
console.error('[DND] Module.canvas not available');
|
|
return;
|
|
}
|
|
|
|
// Prevent default to enable drop
|
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (eventName) {
|
|
canvas.addEventListener(eventName, function (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}, false);
|
|
});
|
|
|
|
canvas.addEventListener('dragenter', function (e) {
|
|
console.log('[DND] dragenter');
|
|
ccall('OnDragEnter', 'void', ['number', 'number'], [e.clientX, e.clientY]);
|
|
});
|
|
|
|
canvas.addEventListener('dragleave', function (e) {
|
|
console.log('[DND] dragleave');
|
|
ccall('OnDragLeave', 'void', [], []);
|
|
});
|
|
|
|
canvas.addEventListener('drop', function (e) {
|
|
var files = e.dataTransfer.files;
|
|
console.log('[DND] drop: ' + files.length + ' files');
|
|
|
|
// Get canvas-relative coordinates
|
|
var rect = canvas.getBoundingClientRect();
|
|
var x = e.clientX - rect.left;
|
|
var y = e.clientY - rect.top;
|
|
|
|
pendingDropFiles = [];
|
|
pendingDropX = x;
|
|
pendingDropY = y;
|
|
|
|
if (files.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Process all files, then notify C++ when all are ready
|
|
var processedCount = 0;
|
|
|
|
for (var i = 0; i < files.length; i++) {
|
|
(function (file) {
|
|
file.arrayBuffer().then(function (arrayBuffer) {
|
|
var array = new Uint8Array(arrayBuffer);
|
|
var path = '/tmp/' + file.name;
|
|
|
|
// Write to WASM filesystem
|
|
var stream = FS.open(path, 'w+');
|
|
if (stream) {
|
|
FS.write(stream, array, 0, file.size);
|
|
FS.close(stream);
|
|
pendingDropFiles.push(path);
|
|
console.log('[DND] Wrote file: ' + path + ' (' + file.size + ' bytes)');
|
|
} else {
|
|
console.error('[DND] Failed to write file: ' + path);
|
|
}
|
|
|
|
processedCount++;
|
|
if (processedCount === files.length) {
|
|
// All files processed, notify C++
|
|
notifyDropComplete();
|
|
}
|
|
}).catch(function (error) {
|
|
console.error('[DND] Error reading file: ' + error);
|
|
processedCount++;
|
|
if (processedCount === files.length) {
|
|
notifyDropComplete();
|
|
}
|
|
});
|
|
})(files[i]);
|
|
}
|
|
});
|
|
|
|
console.log('[DND] Drag and drop handlers registered');
|
|
};
|
|
|
|
var notifyDropComplete = function () {
|
|
if (pendingDropFiles.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Notify C++ for each file
|
|
for (var i = 0; i < pendingDropFiles.length; i++) {
|
|
ccall('OnFileDropped', 'void',
|
|
['string', 'number', 'number'],
|
|
[pendingDropFiles[i], pendingDropX, pendingDropY]);
|
|
}
|
|
|
|
// Clear pending files
|
|
pendingDropFiles = [];
|
|
};
|
|
|