581 lines
24 KiB
JavaScript
581 lines
24 KiB
JavaScript
'use strict';
|
|
// Thin browser shim for TaskForge. All app logic and rendering is in Jai (taskforge.wasm); this file only
|
|
// 1. forwards WebGL2 calls (same names as OpenGL, see src/web_gl.jai),
|
|
// 2. forwards input events to the exported web_* procedures,
|
|
// 3. provides storage (localStorage), clipboard and file import/export.
|
|
// Pointers and s64 values arrive as BigInt (WASM64), everything else as Number.
|
|
|
|
const canvas = document.getElementById('app');
|
|
const message = document.getElementById('message');
|
|
const importInput = document.getElementById('import');
|
|
const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, premultipliedAlpha: false });
|
|
|
|
let memory = null;
|
|
let X = null; // WASM exports.
|
|
|
|
const decoder = new TextDecoder();
|
|
const encoder = new TextEncoder();
|
|
const u8 = () => new Uint8Array(memory.buffer);
|
|
const dv = () => new DataView(memory.buffer);
|
|
const str = (ptr, len) => decoder.decode(u8().subarray(Number(ptr), Number(ptr) + Number(len)));
|
|
const cstr = (ptr) => {
|
|
const m = u8();
|
|
const start = Number(ptr);
|
|
let end = start;
|
|
while (m[end]) end++;
|
|
return decoder.decode(m.subarray(start, end));
|
|
};
|
|
|
|
// WebGL objects are referenced from Jai by integer handles.
|
|
const objects = [null];
|
|
const handle = (o) => { objects.push(o); return objects.length - 1; };
|
|
|
|
function writeInfoLog(log, maxLength, lengthPtr, bufferPtr) {
|
|
const bytes = encoder.encode(log || '').subarray(0, Math.max(0, maxLength - 1));
|
|
u8().set(bytes, Number(bufferPtr));
|
|
u8()[Number(bufferPtr) + bytes.length] = 0;
|
|
if (lengthPtr) dv().setInt32(Number(lengthPtr), bytes.length, true);
|
|
}
|
|
|
|
function genObjects(n, out, create) {
|
|
for (let i = 0; i < n; i++) dv().setUint32(Number(out) + i * 4, handle(create()), true);
|
|
}
|
|
|
|
let pendingLoad = null;
|
|
let consoleLine = '';
|
|
|
|
const env = {
|
|
// Jai runtime.
|
|
wasm_write_string(count, data, toStderr) {
|
|
consoleLine += str(data, count);
|
|
const lines = consoleLine.split('\n');
|
|
consoleLine = lines.pop();
|
|
for (const line of lines) (toStderr ? console.error : console.log)(line);
|
|
},
|
|
wasm_debug_break() { debugger; },
|
|
memcmp(a, b, n) {
|
|
const m = u8();
|
|
const pa = Number(a), pb = Number(b);
|
|
for (let i = 0; i < Number(n); i++) {
|
|
const d = m[pa + i] - m[pb + i];
|
|
if (d) return d;
|
|
}
|
|
return 0;
|
|
},
|
|
|
|
// OpenGL ES 3.0 -> WebGL2.
|
|
glCreateShader: (type) => handle(gl.createShader(type)),
|
|
glShaderSource(shader, count, strings, lengths) {
|
|
const d = dv();
|
|
let source = '';
|
|
for (let i = 0; i < count; i++) {
|
|
const p = d.getBigUint64(Number(strings) + i * 8, true);
|
|
const len = lengths ? d.getInt32(Number(lengths) + i * 4, true) : -1;
|
|
source += len >= 0 ? str(p, len) : cstr(p);
|
|
}
|
|
gl.shaderSource(objects[shader], source);
|
|
},
|
|
glCompileShader: (shader) => gl.compileShader(objects[shader]),
|
|
glGetShaderiv(shader, pname, out) {
|
|
const value = pname === 0x8B84
|
|
? (gl.getShaderInfoLog(objects[shader]) || '').length + 1
|
|
: Number(gl.getShaderParameter(objects[shader], pname));
|
|
dv().setInt32(Number(out), value, true);
|
|
},
|
|
glGetShaderInfoLog: (shader, max, len, buf) => writeInfoLog(gl.getShaderInfoLog(objects[shader]), max, len, buf),
|
|
glCreateProgram: () => handle(gl.createProgram()),
|
|
glAttachShader: (program, shader) => gl.attachShader(objects[program], objects[shader]),
|
|
glBindAttribLocation: (program, index, name) => gl.bindAttribLocation(objects[program], index, cstr(name)),
|
|
glLinkProgram: (program) => gl.linkProgram(objects[program]),
|
|
glGetProgramiv(program, pname, out) {
|
|
const value = pname === 0x8B84
|
|
? (gl.getProgramInfoLog(objects[program]) || '').length + 1
|
|
: Number(gl.getProgramParameter(objects[program], pname));
|
|
dv().setInt32(Number(out), value, true);
|
|
},
|
|
glGetProgramInfoLog: (program, max, len, buf) => writeInfoLog(gl.getProgramInfoLog(objects[program]), max, len, buf),
|
|
glUseProgram: (program) => gl.useProgram(objects[program]),
|
|
glGetUniformLocation: (program, name) => handle(gl.getUniformLocation(objects[program], cstr(name))),
|
|
glUniform1i: (loc, v) => gl.uniform1i(objects[loc], v),
|
|
glUniform1f: (loc, v) => gl.uniform1f(objects[loc], v),
|
|
glUniform2f: (loc, a, b) => gl.uniform2f(objects[loc], a, b),
|
|
glGenVertexArrays: (n, out) => genObjects(n, out, () => gl.createVertexArray()),
|
|
glBindVertexArray: (vao) => gl.bindVertexArray(objects[vao]),
|
|
glGenBuffers: (n, out) => genObjects(n, out, () => gl.createBuffer()),
|
|
glBindBuffer: (target, buffer) => gl.bindBuffer(target, objects[buffer]),
|
|
glBufferData(target, size, data, usage) {
|
|
if (data) gl.bufferData(target, u8().subarray(Number(data), Number(data) + Number(size)), usage);
|
|
else gl.bufferData(target, Number(size), usage);
|
|
},
|
|
glVertexAttribPointer: (index, size, type, normalized, stride, offset) =>
|
|
gl.vertexAttribPointer(index, size, type, !!normalized, stride, Number(offset)),
|
|
glEnableVertexAttribArray: (index) => gl.enableVertexAttribArray(index),
|
|
glGenTextures: (n, out) => genObjects(n, out, () => gl.createTexture()),
|
|
glBindTexture: (target, texture) => gl.bindTexture(target, objects[texture]),
|
|
glActiveTexture: (unit) => gl.activeTexture(unit),
|
|
glTexImage2D(target, level, internalFormat, w, h, border, format, type, pixels) {
|
|
const bytesPerPixel = format === gl.RED ? 1 : 4;
|
|
const data = pixels ? u8().subarray(Number(pixels), Number(pixels) + w * h * bytesPerPixel) : null;
|
|
gl.texImage2D(target, level, internalFormat, w, h, border, format, type, data);
|
|
},
|
|
glTexParameteri: (target, pname, param) => gl.texParameteri(target, pname, param),
|
|
glTexSubImage2D(target, level, x, y, w, h, format, type, pixels) {
|
|
gl.texSubImage2D(target, level, x, y, w, h, format, type, u8().subarray(Number(pixels), Number(pixels) + w * h * 4));
|
|
},
|
|
glDeleteTextures(n, ptr) {
|
|
for (let i = 0; i < n; i++) {
|
|
const h = dv().getUint32(Number(ptr) + i * 4, true);
|
|
if (objects[h]) gl.deleteTexture(objects[h]);
|
|
objects[h] = null;
|
|
}
|
|
},
|
|
glGenerateMipmap: (target) => gl.generateMipmap(target),
|
|
glPixelStorei: (pname, param) => gl.pixelStorei(pname, param),
|
|
glEnable: (cap) => gl.enable(cap),
|
|
glDisable: (cap) => gl.disable(cap),
|
|
glBlendFunc: (s, d) => gl.blendFunc(s, d),
|
|
glScissor: (x, y, w, h) => gl.scissor(x, y, w, h),
|
|
glViewport: (x, y, w, h) => gl.viewport(x, y, w, h),
|
|
glClearColor: (r, g, b, a) => gl.clearColor(r, g, b, a),
|
|
glClear: (mask) => gl.clear(mask),
|
|
glDrawArrays: (mode, first, count) => gl.drawArrays(mode, first, count),
|
|
|
|
// Platform services.
|
|
js_storage_save(key, keyCount, data, count) {
|
|
try { localStorage.setItem(str(key, keyCount), str(data, count)); }
|
|
catch (e) { console.error('Saving failed:', e); }
|
|
},
|
|
js_storage_load_size(key, keyCount) {
|
|
const text = localStorage.getItem(str(key, keyCount));
|
|
pendingLoad = text ? encoder.encode(text) : null;
|
|
return BigInt(pendingLoad ? pendingLoad.length : 0);
|
|
},
|
|
js_storage_load(dest) {
|
|
if (pendingLoad) u8().set(pendingLoad, Number(dest));
|
|
pendingLoad = null;
|
|
},
|
|
js_clipboard_write(data, count) {
|
|
navigator.clipboard?.writeText(str(data, count)).catch(() => {});
|
|
},
|
|
js_set_cursor(kind) {
|
|
canvas.style.cursor = ['default', 'pointer', 'text', 'grabbing'][kind] || 'default';
|
|
},
|
|
js_download(data, count) {
|
|
const blob = new Blob([u8().slice(Number(data), Number(data) + Number(count))], { type: 'text/plain' });
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = 'project.taskforge';
|
|
a.click();
|
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
},
|
|
js_request_import() { importInput.click(); },
|
|
js_http(tag, method, methodCount, path, pathCount, body, bodyCount, token, tokenCount, key, keyCount) {
|
|
const verb = str(method, methodCount);
|
|
const headers = {};
|
|
if (verb !== 'GET') headers['Content-Type'] = 'text/plain; charset=utf-8';
|
|
if (tokenCount) headers['Authorization'] = 'Bearer ' + str(token, tokenCount);
|
|
const keyText = str(key, keyCount);
|
|
const options = { method: verb, headers };
|
|
if (verb !== 'GET') options.body = u8().slice(Number(body), Number(body) + Number(bodyCount));
|
|
fetch(str(path, pathCount), options)
|
|
.then(async (response) => [response.status, new Uint8Array(await response.arrayBuffer())])
|
|
.catch(() => [0, new Uint8Array(0)])
|
|
.then(([status, bytes]) => {
|
|
const [kp, kn] = toWasm(encoder.encode(keyText));
|
|
const [bp, bn] = toWasm(bytes);
|
|
X.web_http_response(tag, status, bp, bn, kp, kn);
|
|
requestFrame();
|
|
});
|
|
},
|
|
|
|
// Images are decoded by the browser, scaled down to at most 2048 px, and handed back as RGBA.
|
|
js_decode_image(key, keyCount, data, count) {
|
|
const keyText = str(key, keyCount);
|
|
const blob = new Blob([u8().slice(Number(data), Number(data) + Number(count))]);
|
|
decodeImage(blob).then((image) => {
|
|
const [kp, kn] = toWasm(encoder.encode(keyText));
|
|
if (!image) { X.web_image_decoded(kp, kn, 0, 0, 0, 0n); requestFrame(); return; }
|
|
const [pp] = toWasm(image.pixels);
|
|
X.web_image_decoded(kp, kn, 1, image.width, image.height, pp);
|
|
requestFrame();
|
|
});
|
|
},
|
|
|
|
// Size, duration and a PNG poster (first frame) of a video.
|
|
js_probe_video(key, keyCount, data, count) {
|
|
const keyText = str(key, keyCount);
|
|
const url = URL.createObjectURL(new Blob([u8().slice(Number(data), Number(data) + Number(count))]));
|
|
probeVideo(url).then((info) => {
|
|
URL.revokeObjectURL(url);
|
|
const [kp, kn] = toWasm(encoder.encode(keyText));
|
|
if (!info) { X.web_video_probed(kp, kn, 0, 0, 0, 0, 0n, 0n); requestFrame(); return; }
|
|
const [pp, pn] = toWasm(info.png);
|
|
X.web_video_probed(kp, kn, 1, info.width, info.height, info.duration, pp, pn);
|
|
requestFrame();
|
|
});
|
|
},
|
|
|
|
// Local projects keep attachments in IndexedDB (localStorage is far too small).
|
|
js_blob_save(key, keyCount, data, count) {
|
|
const keyText = str(key, keyCount);
|
|
const bytes = u8().slice(Number(data), Number(data) + Number(count));
|
|
idb('readwrite', (store) => store.put(bytes, keyText));
|
|
},
|
|
js_blob_load(key, keyCount) {
|
|
const keyText = str(key, keyCount);
|
|
idb('readonly', (store) => store.get(keyText)).then((bytes) => {
|
|
const [kp, kn] = toWasm(encoder.encode(keyText));
|
|
if (!bytes) { X.web_blob_loaded(kp, kn, 0, 0n, 0n); requestFrame(); return; }
|
|
const [dp, dn] = toWasm(bytes);
|
|
X.web_blob_loaded(kp, kn, 1, dp, dn);
|
|
requestFrame();
|
|
}).catch(() => {
|
|
const [kp, kn] = toWasm(encoder.encode(keyText));
|
|
X.web_blob_loaded(kp, kn, 0, 0n, 0n);
|
|
requestFrame();
|
|
});
|
|
},
|
|
|
|
js_pick_files() { mediaInput.click(); },
|
|
|
|
// Grabs one frame of a screen, window or tab the user picks.
|
|
js_capture_screenshot() {
|
|
captureScreen().then((blob) => { if (blob) addFile(screenshotName(), blob); })
|
|
.catch((e) => console.warn('Screen capture cancelled:', e));
|
|
},
|
|
|
|
// One video at a time plays in an off-screen <video>; its frames are uploaded into the given texture.
|
|
js_video_open(data, count) {
|
|
closeVideo();
|
|
const url = URL.createObjectURL(new Blob([u8().slice(Number(data), Number(data) + Number(count))]));
|
|
const v = document.createElement('video');
|
|
v.src = url;
|
|
v.playsInline = true;
|
|
v.muted = videoMuted;
|
|
v.preload = 'auto';
|
|
player = { video: v, url, failed: false };
|
|
v.addEventListener('error', () => { if (player && player.video === v) player.failed = true; requestFrame(); });
|
|
for (const name of ['loadeddata', 'play', 'pause', 'seeked', 'ended']) v.addEventListener(name, requestFrame);
|
|
v.play().catch(() => {});
|
|
return 1;
|
|
},
|
|
js_video_update(texture, state) {
|
|
const d = dv();
|
|
const base = Number(state);
|
|
if (!player) return;
|
|
const v = player.video;
|
|
const ready = v.readyState >= 2 && v.videoWidth > 0;
|
|
if (ready) {
|
|
gl.bindTexture(gl.TEXTURE_2D, objects[texture]);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, v);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
}
|
|
d.setFloat64(base + 0, v.currentTime || 0, true);
|
|
d.setFloat64(base + 8, isFinite(v.duration) ? v.duration : 0, true);
|
|
d.setInt32(base + 16, (!v.paused && !v.ended) ? 1 : 0, true);
|
|
d.setInt32(base + 20, ready ? 1 : 0, true);
|
|
d.setInt32(base + 24, v.videoWidth, true);
|
|
d.setInt32(base + 28, v.videoHeight, true);
|
|
d.setInt32(base + 32, player.failed ? 1 : 0, true);
|
|
},
|
|
js_video_set_playing(playing) {
|
|
if (!player) return;
|
|
if (playing) player.video.play().catch(() => {});
|
|
else player.video.pause();
|
|
},
|
|
js_video_seek(seconds) { if (player) player.video.currentTime = seconds; },
|
|
js_video_set_muted(muted) { videoMuted = !!muted; if (player) player.video.muted = videoMuted; },
|
|
js_video_close() { closeVideo(); },
|
|
};
|
|
|
|
//
|
|
// Media helpers.
|
|
//
|
|
|
|
let player = null;
|
|
let videoMuted = false;
|
|
|
|
function closeVideo() {
|
|
if (!player) return;
|
|
player.video.pause();
|
|
player.video.removeAttribute('src');
|
|
player.video.load();
|
|
URL.revokeObjectURL(player.url);
|
|
player = null;
|
|
}
|
|
|
|
// Copies bytes into WASM memory (allocated with web_alloc; Jai frees them). Returns [pointer, length] as BigInts.
|
|
function toWasm(bytes) {
|
|
if (!bytes || !bytes.length) return [0n, 0n];
|
|
const ptr = X.web_alloc(BigInt(bytes.length));
|
|
u8().set(bytes, Number(ptr));
|
|
return [ptr, BigInt(bytes.length)];
|
|
}
|
|
|
|
const MAX_IMAGE_SIDE = 2048;
|
|
|
|
async function decodeImage(blob) {
|
|
try {
|
|
let bitmap = await createImageBitmap(blob);
|
|
const scale = Math.min(1, MAX_IMAGE_SIDE / Math.max(bitmap.width, bitmap.height));
|
|
const w = Math.max(1, Math.round(bitmap.width * scale)), h = Math.max(1, Math.round(bitmap.height * scale));
|
|
const canvas2 = new OffscreenCanvas(w, h);
|
|
const ctx = canvas2.getContext('2d');
|
|
ctx.drawImage(bitmap, 0, 0, w, h);
|
|
bitmap.close();
|
|
const data = ctx.getImageData(0, 0, w, h).data;
|
|
return { width: w, height: h, pixels: new Uint8Array(data.buffer, data.byteOffset, data.byteLength) };
|
|
} catch (e) {
|
|
console.warn('Could not decode image:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function probeVideo(url) {
|
|
return new Promise((resolve) => {
|
|
const v = document.createElement('video');
|
|
v.muted = true;
|
|
v.playsInline = true;
|
|
v.preload = 'auto';
|
|
const timer = setTimeout(() => resolve(null), 20000);
|
|
v.addEventListener('error', () => { clearTimeout(timer); resolve(null); });
|
|
v.addEventListener('loadeddata', () => { v.currentTime = Math.min(0.1, (v.duration || 1) / 2); });
|
|
v.addEventListener('seeked', async () => {
|
|
clearTimeout(timer);
|
|
const scale = Math.min(1, 1280 / Math.max(v.videoWidth, v.videoHeight));
|
|
const c = new OffscreenCanvas(Math.max(1, Math.round(v.videoWidth * scale)), Math.max(1, Math.round(v.videoHeight * scale)));
|
|
c.getContext('2d').drawImage(v, 0, 0, c.width, c.height);
|
|
const png = new Uint8Array(await (await c.convertToBlob({ type: 'image/png' })).arrayBuffer());
|
|
resolve({ width: v.videoWidth, height: v.videoHeight, duration: isFinite(v.duration) ? v.duration : 0, png });
|
|
}, { once: true });
|
|
v.src = url;
|
|
});
|
|
}
|
|
|
|
async function captureScreen() {
|
|
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
|
|
try {
|
|
const v = document.createElement('video');
|
|
v.srcObject = stream;
|
|
v.muted = true;
|
|
await v.play();
|
|
await new Promise((r) => setTimeout(r, 300)); // Let the first real frame arrive.
|
|
const c = new OffscreenCanvas(v.videoWidth, v.videoHeight);
|
|
c.getContext('2d').drawImage(v, 0, 0);
|
|
return await c.convertToBlob({ type: 'image/png' });
|
|
} finally {
|
|
for (const track of stream.getTracks()) track.stop();
|
|
}
|
|
}
|
|
|
|
function screenshotName() {
|
|
const d = new Date();
|
|
const p = (n) => String(n).padStart(2, '0');
|
|
return `Screenshot ${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}.${p(d.getMinutes())}.${p(d.getSeconds())}.png`;
|
|
}
|
|
|
|
async function addFile(name, blob) {
|
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
const [np, nn] = toWasm(encoder.encode(name));
|
|
const [dp, dn] = toWasm(bytes);
|
|
X.web_add_file(np, nn, dp, dn);
|
|
requestFrame();
|
|
}
|
|
|
|
let dbPromise = null;
|
|
function idb(mode, action) {
|
|
if (!dbPromise) {
|
|
dbPromise = new Promise((resolve, reject) => {
|
|
const request = indexedDB.open('taskforge', 1);
|
|
request.onupgradeneeded = () => request.result.createObjectStore('blobs');
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
return dbPromise.then((db) => new Promise((resolve, reject) => {
|
|
const request = action(db.transaction('blobs', mode).objectStore('blobs'));
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
}));
|
|
}
|
|
|
|
const mediaInput = document.createElement('input');
|
|
mediaInput.type = 'file';
|
|
mediaInput.multiple = true;
|
|
mediaInput.accept = 'image/*,video/*';
|
|
mediaInput.addEventListener('change', () => {
|
|
for (const file of mediaInput.files) addFile(file.name, file);
|
|
mediaInput.value = '';
|
|
});
|
|
|
|
// Report any import we forgot instead of failing to instantiate.
|
|
const imports = {
|
|
env: new Proxy(env, {
|
|
get(target, name) {
|
|
if (name in target) return target[name];
|
|
return () => { throw new Error('Missing import: ' + String(name)); };
|
|
},
|
|
}),
|
|
};
|
|
|
|
//
|
|
// Frames are only rendered when something happened (input, resize, or the app asks for more).
|
|
//
|
|
let frameScheduled = false;
|
|
function requestFrame() {
|
|
if (!frameScheduled) {
|
|
frameScheduled = true;
|
|
requestAnimationFrame(frame);
|
|
}
|
|
}
|
|
|
|
function frame(timeMs) {
|
|
frameScheduled = false;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const w = canvas.clientWidth, h = canvas.clientHeight;
|
|
const pw = Math.round(w * dpr), ph = Math.round(h * dpr);
|
|
if (canvas.width !== pw || canvas.height !== ph) {
|
|
canvas.width = pw;
|
|
canvas.height = ph;
|
|
}
|
|
const now = Date.now() / 1000;
|
|
const utcOffset = -new Date().getTimezoneOffset() * 60;
|
|
if (X.web_frame(w, h, dpr, timeMs / 1000, now, utcOffset)) requestFrame();
|
|
}
|
|
|
|
function sendString(text, exportName) {
|
|
const bytes = encoder.encode(text);
|
|
const ptr = X.web_alloc(BigInt(bytes.length));
|
|
u8().set(bytes, Number(ptr));
|
|
X[exportName](ptr, BigInt(bytes.length));
|
|
}
|
|
|
|
//
|
|
// Input
|
|
//
|
|
const KEYS = {
|
|
Backspace: 8, Tab: 9, Enter: 13, Escape: 27, Delete: 127,
|
|
ArrowUp: 128, ArrowDown: 129, ArrowLeft: 130, ArrowRight: 131,
|
|
PageUp: 132, PageDown: 133, Home: 134, End: 135,
|
|
};
|
|
const MOUSE_BUTTONS = [0, 2, 1]; // DOM left/middle/right -> app left/middle/right indices.
|
|
|
|
function modifiers(e) {
|
|
return (e.shiftKey ? 1 : 0) | (e.ctrlKey || e.metaKey ? 2 : 0) | (e.altKey ? 4 : 0);
|
|
}
|
|
|
|
function mouseMove(e) {
|
|
const r = canvas.getBoundingClientRect();
|
|
X.web_mouse_move(e.clientX - r.left, e.clientY - r.top);
|
|
X.web_modifiers(modifiers(e));
|
|
requestFrame();
|
|
}
|
|
|
|
function installInput() {
|
|
window.addEventListener('pointermove', mouseMove);
|
|
canvas.addEventListener('pointerdown', (e) => {
|
|
canvas.focus();
|
|
canvas.setPointerCapture(e.pointerId);
|
|
mouseMove(e);
|
|
X.web_mouse_button(MOUSE_BUTTONS[e.button] ?? 0, 1);
|
|
e.preventDefault();
|
|
});
|
|
window.addEventListener('pointerup', (e) => {
|
|
mouseMove(e);
|
|
X.web_mouse_button(MOUSE_BUTTONS[e.button] ?? 0, 0);
|
|
});
|
|
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
// Drag & drop images and videos onto the window.
|
|
window.addEventListener('dragover', (e) => { e.preventDefault(); });
|
|
window.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
mouseMove(e);
|
|
for (const f of e.dataTransfer?.files || []) addFile(f.name, f);
|
|
});
|
|
canvas.addEventListener('wheel', (e) => {
|
|
const scale = e.deltaMode === 1 ? 1 : e.deltaMode === 2 ? 10 : 1 / 40;
|
|
X.web_modifiers(modifiers(e));
|
|
X.web_wheel(e.deltaX * scale, e.deltaY * scale);
|
|
requestFrame();
|
|
e.preventDefault();
|
|
}, { passive: false });
|
|
|
|
window.addEventListener('keydown', (e) => {
|
|
const mods = modifiers(e);
|
|
const ctrl = (mods & 2) !== 0;
|
|
X.web_modifiers(mods);
|
|
if (KEYS[e.key] !== undefined) {
|
|
X.web_key(KEYS[e.key], mods);
|
|
e.preventDefault();
|
|
} else if (ctrl) {
|
|
// Ctrl+V is left to the browser so the 'paste' event delivers the clipboard text.
|
|
const k = e.key.toUpperCase();
|
|
if (k === 'C' || k === 'X' || k === 'S' || k === 'A') {
|
|
X.web_key(k.charCodeAt(0), mods);
|
|
e.preventDefault();
|
|
}
|
|
} else if ([...e.key].length === 1) {
|
|
X.web_char(e.key.codePointAt(0));
|
|
e.preventDefault();
|
|
}
|
|
requestFrame();
|
|
});
|
|
window.addEventListener('keyup', (e) => { X.web_modifiers(modifiers(e)); });
|
|
window.addEventListener('paste', (e) => {
|
|
// Pasted images (e.g. a screenshot) become attachments; otherwise paste the text.
|
|
const files = [...(e.clipboardData?.files || [])].filter((f) => f.type.startsWith('image/') || f.type.startsWith('video/'));
|
|
if (files.length) {
|
|
for (const f of files) addFile(f.name && f.name !== 'image.png' ? f.name : screenshotName(), f);
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
const text = e.clipboardData?.getData('text') || '';
|
|
for (const ch of text.replace(/\r\n/g, '\n')) X.web_char(ch.codePointAt(0));
|
|
e.preventDefault();
|
|
requestFrame();
|
|
});
|
|
|
|
window.addEventListener('resize', requestFrame);
|
|
window.addEventListener('beforeunload', () => X.web_flush());
|
|
// Keep the caret blinking and autosave ticking even without input.
|
|
setInterval(requestFrame, 500);
|
|
|
|
importInput.addEventListener('change', async () => {
|
|
const file = importInput.files[0];
|
|
importInput.value = '';
|
|
if (!file) return;
|
|
sendString(await file.text(), 'web_import');
|
|
requestFrame();
|
|
});
|
|
}
|
|
|
|
async function start() {
|
|
if (!gl) throw new Error('WebGL2 is not available in this browser.');
|
|
// When served by taskforge-server we run against it (with sign-in); otherwise the project lives in this browser.
|
|
const serverMode = await fetch('api/info').then((r) => r.ok ? r.text() : '').then((t) => t.startsWith('taskforge-server')).catch(() => false);
|
|
const [wasm, fonts] = await Promise.all([
|
|
fetch('taskforge.wasm').then((r) => r.arrayBuffer()).then((bytes) => WebAssembly.instantiate(bytes, imports)),
|
|
fetch('fonts.bin').then((r) => r.arrayBuffer()),
|
|
]);
|
|
X = wasm.instance.exports;
|
|
memory = X.memory;
|
|
X.main(0, 0n); // Runs the Jai runtime init + our main(), which just stores the context.
|
|
|
|
const ptr = X.web_alloc(BigInt(fonts.byteLength));
|
|
u8().set(new Uint8Array(fonts), Number(ptr));
|
|
if (!X.web_init(ptr, BigInt(fonts.byteLength), serverMode ? 1 : 0)) throw new Error('Initialization failed (see console).');
|
|
|
|
message.remove();
|
|
installInput();
|
|
canvas.focus();
|
|
requestFrame();
|
|
}
|
|
|
|
start().catch((e) => {
|
|
console.error(e);
|
|
const memory64 = /memory64|i64|Memory64|table64/i.test(String(e));
|
|
message.textContent = memory64
|
|
? 'This browser does not support WebAssembly Memory64 yet. Try a current Chrome, Edge or Firefox.'
|
|
: 'TaskForge failed to start: ' + e.message;
|
|
});
|