blob: 5826072ffc208c58b8c1cb69e14ad51e9af44724 [file] [log] [blame]
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>HarfBuzz GPU Demo</title>
<script>
if (location.pathname.indexOf('webgpu') !== -1)
document.title = 'HarfBuzz WebGPU Demo';
else
document.title = 'HarfBuzz WebGL Demo';
</script>
<style>
body { margin: 0; background: #fff; overflow: hidden; touch-action: none; }
#loading { position: fixed; inset: 0; z-index: 100;
display: flex; justify-content: center; align-items: center;
background: #fff; color: #999; font: 32px monospace; }
canvas { display: block; width: 100vw; height: 100vh; }
#fps { position: fixed; top: 8px; right: 12px; color: #999;
font: 14px monospace; pointer-events: none; z-index: 1; }
#help { position: fixed; bottom: 12px; left: 12px; color: #999;
font: 12px monospace; pointer-events: none; z-index: 1;
line-height: 1.6; }
#controls { position: fixed; top: 8px; left: 12px; z-index: 1;
display: flex; gap: 6px; }
@media (hover: none) { #controls { display: none; }
#help-desktop { display: none; } }
@media (hover: hover) { #help-mobile { display: none; } }
#controls label { color: #fff; font: 12px monospace; cursor: pointer;
padding: 4px 8px; border: 1px solid #888; border-radius: 4px;
background: rgba(128,128,128,0.5);
text-shadow: 0 1px 2px rgba(0,0,0,0.8); }
#controls label:hover { border-color: #ccc; background: rgba(128,128,128,0.7); }
#controls input[type=file] { display: none; }
#controls button { color: #fff; font: 12px monospace; cursor: pointer;
padding: 4px 8px; border: 1px solid #888; border-radius: 4px;
background: rgba(128,128,128,0.5);
text-shadow: 0 1px 2px rgba(0,0,0,0.8); }
#controls button:hover { border-color: #ccc; background: rgba(128,128,128,0.7); }
#controls select { color: #fff; font: 12px monospace; cursor: pointer;
padding: 3px 4px; border: 1px solid #888; border-radius: 4px;
background: rgba(128,128,128,0.5);
text-shadow: 0 1px 2px rgba(0,0,0,0.8); }
#drop-overlay { display: none; position: fixed; inset: 0; z-index: 10;
background: rgba(0,0,0,0.6); color: #fff;
font: 24px monospace; justify-content: center;
align-items: center; }
#text-modal { display: none; position: fixed; inset: 0; z-index: 20;
background: rgba(0,0,0,0.7); justify-content: center;
align-items: center; }
#text-modal-inner { background: #333; border: 1px solid #666; border-radius: 8px;
padding: 16px; width: 90vw; max-width: 1600px; }
#text-modal textarea { width: 100%; height: 70vh; background: #222; color: #ccc;
border: 1px solid #555; border-radius: 4px;
font: 14px monospace; padding: 8px; resize: vertical;
outline: none; box-sizing: border-box; }
#text-modal textarea:focus { border-color: #999; }
#text-modal-buttons { margin-top: 10px; text-align: right; }
#text-modal-buttons button { color: #fff; font: 13px monospace; cursor: pointer;
padding: 6px 16px; border: 1px solid #888;
border-radius: 4px; background: rgba(128,128,128,0.5);
margin-left: 8px; }
#text-modal-buttons button:hover { border-color: #ccc; background: rgba(128,128,128,0.7); }
#help-modal { display: none; position: fixed; inset: 0; z-index: 20;
background: rgba(0,0,0,0.7); justify-content: center;
align-items: center; }
#help-modal pre { background: #333; color: #ccc; font: 14px monospace;
padding: 24px 32px; border-radius: 8px; border: 1px solid #666;
line-height: 1.8; }
</style>
</head>
<body>
<div id="loading">loading</div>
<div id="fps"></div>
<div id="controls">
<label>font <input type="file" id="font-input" accept=".ttf,.otf"></label>
<button id="text-btn">text</button>
<select id="backend-sel">
<option value="index.html">WebGL</option>
<option value="webgpu.html">WebGPU</option>
</select>
</div>
<div id="drop-overlay">drop font file</div>
<div id="text-modal">
<div id="text-modal-inner">
<textarea id="text-area"></textarea>
<div id="text-modal-buttons">
<button id="text-cancel">cancel</button>
<button id="text-apply">apply</button>
</div>
</div>
</div>
<div id="help-modal"><pre>
Keyboard: Mouse:
space toggle animation drag pan
b dark mode scroll zoom
g gamma (2.2/none) right-drag rotate
s stem darkening click toggle animation
d debug heatmap dblclick reset view
r reset view middle-drag zoom
=/- zoom in/out
[ ] { } stretch Touch:
arrows pan drag pan
h j k l pan (vim) pinch zoom
/ clean screen 3-finger rotate
? this help tap animate
Shortcuts: Text editor:
t edit text Ctrl+Enter apply
f load font Esc cancel
w switch backend
R reload page <a href="https://github.com/harfbuzz/harfbuzz" style="color:#88f">HarfBuzz</a>
</pre></div>
<div id="help">
<span id="help-desktop">drag: pan · scroll: zoom · click: animate · dblclick: reset · right-drag: rotate<br>
space: animate · b: dark mode · g: gamma · s: stem · d: debug · r: reset · ?: help</span>
<span id="help-mobile">drag: pan · pinch: zoom/rotate · 3-finger: 3D rotate<br>
tap: animate · dbl-tap+drag: zoom · long-press: reset</span>
</div>
<canvas id="canvas" oncontextmenu="event.preventDefault()" tabindex="-1"></canvas>
<script>
var canvas = document.getElementById('canvas');
function resizeCanvas () {
var dpr = window.devicePixelRatio || 1;
var width = Math.round(canvas.clientWidth * dpr);
var height = Math.round(canvas.clientHeight * dpr);
if (canvas.width !== width)
canvas.width = width;
if (canvas.height !== height)
canvas.height = height;
}
function requestRedraw () {
if (typeof _web_request_redraw === 'function')
_web_request_redraw();
}
function handleResume () {
resizeCanvas();
requestRedraw();
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
window.addEventListener('focus', handleResume);
window.addEventListener('pageshow', handleResume);
document.addEventListener('visibilitychange', function () {
if (!document.hidden)
handleResume();
});
canvas.addEventListener('webglcontextrestored', handleResume);
/* Backend selector */
var isWebGPU = location.pathname.indexOf('webgpu') !== -1;
var backendSel = document.getElementById('backend-sel');
if (isWebGPU)
backendSel.value = 'webgpu.html';
backendSel.addEventListener('change', function () {
backendSel.blur();
location.href = backendSel.value;
});
/* Early WebGL2 availability check */
if (!isWebGPU && !canvas.getContext('webgl2')) {
var el = document.getElementById('loading');
el.innerHTML = 'WebGL2 not available.<br>' +
'Check that hardware acceleration is enabled in your browser settings.<br>' +
'<a href="https://webglreport.com/?v=2" style="color:#88f">WebGL2 support status</a>';
el.style.font = '18px sans-serif';
el.style.color = '#666';
el.style.textAlign = 'center';
el.style.flexDirection = 'column';
window._webglError = true;
}
/* Early WebGPU availability check */
if (isWebGPU && !navigator.gpu) {
var el = document.getElementById('loading');
var html = 'WebGPU not available in this browser.<br>' +
'<a href="https://github.com/gpuweb/gpuweb/wiki/Implementation-Status" ' +
'style="color:#88f">Browser support status</a>';
if (navigator.userAgent.indexOf('Linux') !== -1 &&
navigator.userAgent.indexOf('Chrome') !== -1)
html += '<br><br>On Linux Chrome, try restarting with:<br>' +
'<code style="font-size:13px">google-chrome --enable-unsafe-webgpu --ozone-platform=x11 ' +
'--use-angle=vulkan --enable-features=Vulkan,VulkanFromANGLE</code>';
el.innerHTML = html;
el.style.font = '18px sans-serif';
el.style.color = '#666';
el.style.textAlign = 'center';
el.style.flexDirection = 'column';
window._webgpuError = true;
}
var fpsEl = document.getElementById('fps');
var frames = 0, lastTime = performance.now();
(function fpsLoop () {
requestAnimationFrame(fpsLoop);
frames++;
var now = performance.now();
if (now - lastTime >= 1000) {
fpsEl.textContent = Math.round(frames * 1000 / (now - lastTime)) + ' fps';
frames = 0;
lastTime = now;
}
})();
/* Multi-touch: 2-finger pinch → zoom, 3-finger drag → 3D rotate. */
var pinchDist = 0;
var pinchAngle = 0;
var pinchCX = 0, pinchCY = 0;
var gestureX = 0, gestureY = 0;
var pinchActive = false;
var singlePanActive = false;
var threeFingerActive = false;
var skipNextPinchMove = false;
var longPressTimer = null;
var longPressFired = false;
var lastTapTime = 0;
var doubleTapZoom = false;
var doubleTapY = 0;
function swallowTouch (e) {
e.preventDefault();
e.stopImmediatePropagation();
}
if (!isWebGPU) canvas.addEventListener('touchstart', function (e) {
if (e.touches.length === 1) {
var now = performance.now();
if (now - lastTapTime < 300) {
/* Double-tap: start zoom drag.
* Undo animation toggle from first tap. */
e.preventDefault();
if (typeof _web_toggle_animation === 'function')
_web_toggle_animation();
doubleTapZoom = true;
doubleTapY = e.touches[0].clientY;
lastTapTime = 0;
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
} else {
lastTapTime = now;
longPressX = e.touches[0].clientX;
longPressY = e.touches[0].clientY;
longPressTimer = setTimeout(function () {
longPressTimer = null;
longPressFired = true;
if (typeof _web_reset === 'function') _web_reset();
}, 500);
}
} else if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
if (e.touches.length === 2) {
swallowTouch(e);
pinchActive = true;
singlePanActive = false;
var t = e.touches;
pinchDist = Math.hypot(t[1].clientX - t[0].clientX,
t[1].clientY - t[0].clientY);
pinchAngle = Math.atan2(t[1].clientY - t[0].clientY,
t[1].clientX - t[0].clientX);
pinchCX = (t[0].clientX + t[1].clientX) / 2;
pinchCY = (t[0].clientY + t[1].clientY) / 2;
gestureX = pinchCX;
gestureY = pinchCY;
if (typeof _web_cancel_gesture === 'function')
_web_cancel_gesture();
canvas.dispatchEvent(new MouseEvent('mouseup', {
button: 0, clientX: pinchCX, clientY: pinchCY
}));
skipNextPinchMove = false;
} else if (e.touches.length >= 3) {
swallowTouch(e);
pinchActive = false;
singlePanActive = false;
threeFingerActive = true;
skipNextPinchMove = false;
var t = e.touches;
var cx = (t[0].clientX + t[1].clientX + t[2].clientX) / 3;
var cy = (t[0].clientY + t[1].clientY + t[2].clientY) / 3;
gestureX = cx;
gestureY = cy;
if (typeof _web_cancel_gesture === 'function')
_web_cancel_gesture();
canvas.dispatchEvent(new MouseEvent('mouseup', {
button: 0, clientX: cx, clientY: cy
}));
/* Synthesize right-button press at centroid */
canvas.dispatchEvent(new MouseEvent('mousedown', {
button: 2, clientX: cx, clientY: cy
}));
}
}, {passive: false, capture: true});
var longPressX = 0, longPressY = 0;
if (!isWebGPU) canvas.addEventListener('touchmove', function (e) {
if (singlePanActive && e.touches.length === 1) {
swallowTouch(e);
gestureX = e.touches[0].clientX;
gestureY = e.touches[0].clientY;
canvas.dispatchEvent(new MouseEvent('mousemove', {
clientX: gestureX, clientY: gestureY
}));
return;
}
if (longPressTimer && e.touches.length === 1) {
var dx = e.touches[0].clientX - longPressX;
var dy = e.touches[0].clientY - longPressY;
if (dx*dx + dy*dy > 100) { clearTimeout(longPressTimer); longPressTimer = null; }
}
if (doubleTapZoom && e.touches.length === 1) {
swallowTouch(e);
var dy = e.touches[0].clientY - doubleTapY;
doubleTapY = e.touches[0].clientY;
canvas.dispatchEvent(new WheelEvent('wheel', {
deltaY: dy * 5,
clientX: e.touches[0].clientX,
clientY: e.touches[0].clientY
}));
return;
}
if (e.touches.length >= 3 && threeFingerActive) {
swallowTouch(e);
pinchActive = false;
var t = e.touches;
var cx = (t[0].clientX + t[1].clientX + t[2].clientX) / 3;
var cy = (t[0].clientY + t[1].clientY + t[2].clientY) / 3;
gestureX = cx;
gestureY = cy;
canvas.dispatchEvent(new MouseEvent('mousemove', {
clientX: cx, clientY: cy
}));
return;
}
if (e.touches.length === 2) {
swallowTouch(e);
pinchActive = true;
var t = e.touches;
var cx = (t[0].clientX + t[1].clientX) / 2;
var cy = (t[0].clientY + t[1].clientY) / 2;
var dist = Math.hypot(t[1].clientX - t[0].clientX,
t[1].clientY - t[0].clientY);
var angle = Math.atan2(t[1].clientY - t[0].clientY,
t[1].clientX - t[0].clientX);
if (skipNextPinchMove) {
pinchDist = dist;
pinchAngle = angle;
pinchCX = cx;
pinchCY = cy;
gestureX = cx;
gestureY = cy;
skipNextPinchMove = false;
return;
}
var dAngle = angle - pinchAngle;
if (dAngle > Math.PI) dAngle -= 2 * Math.PI;
if (dAngle < -Math.PI) dAngle += 2 * Math.PI;
var factor = pinchDist > 0 ? dist / pinchDist : 1;
if (typeof _web_pinch === 'function')
_web_pinch(cx - pinchCX, cy - pinchCY,
factor, dAngle,
cx, cy, canvas.clientWidth, canvas.clientHeight);
else {
/* Fallback: zoom only via wheel */
var delta = (factor - 1) * 5;
canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: -delta * 120 }));
}
pinchDist = dist;
pinchAngle = angle;
pinchCX = cx; pinchCY = cy;
gestureX = cx;
gestureY = cy;
}
}, {passive: false, capture: true});
if (!isWebGPU) canvas.addEventListener('touchend', function (e) {
var pinchEnding = pinchActive && e.touches.length < 2;
var singlePanEnding = singlePanActive && e.touches.length === 0;
if (threeFingerActive || pinchEnding || singlePanEnding ||
skipNextPinchMove ||
e.touches.length >= 2 || doubleTapZoom)
swallowTouch(e);
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
if (longPressFired) {
longPressFired = false;
e.preventDefault(); /* Don't let GLFW see this as a click */
}
doubleTapZoom = false;
if (threeFingerActive && e.touches.length < 3) {
threeFingerActive = false;
var releaseX = gestureX, releaseY = gestureY;
if (e.touches.length === 2) {
var t = e.touches;
releaseX = (t[0].clientX + t[1].clientX) / 2;
releaseY = (t[0].clientY + t[1].clientY) / 2;
pinchDist = Math.hypot(t[1].clientX - t[0].clientX,
t[1].clientY - t[0].clientY);
pinchAngle = Math.atan2(t[1].clientY - t[0].clientY,
t[1].clientX - t[0].clientX);
pinchCX = releaseX;
pinchCY = releaseY;
gestureX = releaseX;
gestureY = releaseY;
pinchActive = true;
skipNextPinchMove = true;
} else {
pinchActive = false;
skipNextPinchMove = false;
}
if (typeof _web_cancel_gesture === 'function')
_web_cancel_gesture();
canvas.dispatchEvent(new MouseEvent('mouseup', {
button: 2, clientX: releaseX, clientY: releaseY
}));
} else if (pinchEnding) {
pinchActive = false;
skipNextPinchMove = false;
if (e.touches.length === 1) {
/* Hand single-finger pan off from the remaining touch point. */
singlePanActive = true;
gestureX = e.touches[0].clientX;
gestureY = e.touches[0].clientY;
canvas.dispatchEvent(new MouseEvent('mousemove', {
clientX: gestureX,
clientY: gestureY
}));
canvas.dispatchEvent(new MouseEvent('mousedown', {
button: 0,
clientX: gestureX,
clientY: gestureY
}));
}
} else if (singlePanEnding) {
singlePanActive = false;
if (typeof _web_cancel_gesture === 'function')
_web_cancel_gesture();
canvas.dispatchEvent(new MouseEvent('mouseup', {
button: 0, clientX: gestureX, clientY: gestureY
}));
} else if (e.touches.length < 2) {
skipNextPinchMove = false;
}
}, {passive: false, capture: true});
if (!isWebGPU) canvas.addEventListener('touchcancel', function (e) {
var handledGesture = threeFingerActive || pinchActive ||
singlePanActive ||
skipNextPinchMove ||
e.touches.length >= 2 || doubleTapZoom;
if (handledGesture)
swallowTouch(e);
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
longPressFired = false;
doubleTapZoom = false;
pinchActive = false;
if (singlePanActive) {
singlePanActive = false;
if (typeof _web_cancel_gesture === 'function')
_web_cancel_gesture();
canvas.dispatchEvent(new MouseEvent('mouseup', {
button: 0, clientX: gestureX, clientY: gestureY
}));
}
skipNextPinchMove = false;
if (threeFingerActive) {
threeFingerActive = false;
if (typeof _web_cancel_gesture === 'function')
_web_cancel_gesture();
canvas.dispatchEvent(new MouseEvent('mouseup', {
button: 2, clientX: gestureX, clientY: gestureY
}));
}
}, {passive: false, capture: true});
/* Block Emscripten/GLFW from eating key events while the text modal is open.
* Registered before Module so it runs before Emscripten's capture-phase handlers. */
var textModalEl = document.getElementById('text-modal');
function blockKeysIfModal(e) {
if (textModalEl.style.display !== 'flex') {
if (e.key === '?') {
if (e.type === 'keydown') {
var hm = document.getElementById('help-modal');
hm.style.display = hm.style.display === 'flex' ? 'none' : 'flex';
}
e.preventDefault();
e.stopImmediatePropagation();
return;
}
if (e.key === '/') {
if (e.type === 'keydown') {
var h = document.getElementById('help');
var fp = document.getElementById('fps');
var c = document.getElementById('controls');
var hidden = h.style.display === 'none';
h.style.display = hidden ? '' : 'none';
fp.style.display = hidden ? '' : 'none';
if (c) c.style.display = hidden ? '' : 'none';
}
e.preventDefault();
e.stopImmediatePropagation();
return;
}
if (e.key === 'Escape') {
var hm = document.getElementById('help-modal');
if (hm.style.display === 'flex') {
hm.style.display = 'none';
e.stopImmediatePropagation();
return;
}
}
if (e.key === 'R') {
location.reload();
return;
}
if (e.key === 't') {
e.preventDefault();
if (e.type === 'keydown')
document.getElementById('text-btn').click();
e.stopImmediatePropagation();
return;
}
if (e.type === 'keydown' && e.key === 'f') {
document.getElementById('font-input').click();
e.stopImmediatePropagation();
}
if (e.type === 'keydown' && e.key === 'w') {
var sel = document.getElementById('backend-sel');
sel.value = sel.value === 'webgpu.html' ? 'index.html' : 'webgpu.html';
location.href = sel.value;
e.stopImmediatePropagation();
}
return;
}
if (e.key === 'Escape') { textModalEl.style.display = 'none'; e.stopImmediatePropagation(); return; }
if (e.key === 'Enter' && (e.shiftKey || e.ctrlKey || e.metaKey)) {
document.getElementById('text-apply').click();
e.stopImmediatePropagation(); return;
}
e.stopImmediatePropagation();
}
window.addEventListener('keydown', blockKeysIfModal, true);
window.addEventListener('keyup', blockKeysIfModal, true);
window.addEventListener('keypress', blockKeysIfModal, true);
var Module = {
canvas: canvas,
onRuntimeInitialized: function () {
if (!window._webgpuError && !window._webglError) {
document.getElementById('loading').style.display = 'none';
document.body.style.background = '#222';
}
console.log('HarfBuzz GPU Demo loaded');
/* Font file input */
var fontInput = document.getElementById('font-input');
fontInput.addEventListener('change', function () {
if (fontInput.files.length)
loadFontFile(fontInput.files[0]);
});
/* Drag and drop */
var overlay = document.getElementById('drop-overlay');
document.addEventListener('dragover', function (e) {
e.preventDefault();
overlay.style.display = 'flex';
});
document.addEventListener('dragleave', function (e) {
if (e.relatedTarget === null)
overlay.style.display = 'none';
});
document.addEventListener('drop', function (e) {
e.preventDefault();
overlay.style.display = 'none';
if (e.dataTransfer.files.length)
loadFontFile(e.dataTransfer.files[0]);
});
/* Text editor modal */
var textModal = document.getElementById('text-modal');
var textArea = document.getElementById('text-area');
var textBtn = document.getElementById('text-btn');
textBtn.addEventListener('click', function () {
var ptr = _web_get_text();
textArea.value = UTF8ToString(ptr);
textModal.style.display = 'flex';
textArea.focus();
});
document.getElementById('text-cancel').addEventListener('click', function () {
textModal.style.display = 'none';
});
document.getElementById('text-apply').addEventListener('click', function () {
var text = textArea.value.trim();
if (!text) { textModal.style.display = 'none'; return; }
var len = lengthBytesUTF8(text) + 1;
var buf = _malloc(len);
stringToUTF8(text, buf, len);
_web_set_text(buf);
_free(buf);
textModal.style.display = 'none';
});
/* Prevent mouse events on the modal from reaching the canvas/GLFW. */
textModal.addEventListener('mousedown', function (e) { e.stopPropagation(); });
textModal.addEventListener('mouseup', function (e) { e.stopPropagation(); });
textModal.addEventListener('click', function (e) { e.stopPropagation(); });
function loadFontFile(file) {
var reader = new FileReader();
reader.onload = function () {
var data = new Uint8Array(reader.result);
var buf = _malloc(data.length);
HEAPU8.set(data, buf);
_web_load_font(buf, data.length);
_free(buf);
};
reader.readAsArrayBuffer(file);
}
}
};
</script>
{{{ SCRIPT }}}
</body>
</html>