| <!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; } | |
| /* Embed mode (?embed=1): hide UI chrome; host page drives | |
| * the demo via postMessage. */ | |
| html.embed #controls, | |
| html.embed #fps, | |
| html.embed #help, | |
| html.embed #help-mobile, | |
| html.embed #text-btn { display: none !important; } | |
| </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 | |
| 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 · 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); | |
| /* Skip zero-sized updates -- iframe-hosted case runs | |
| * this at parse time before layout, and installing a | |
| * 0x0 drawing buffer then leaves GL stuck at 0x0 even | |
| * after the canvas later gets a real size. Leaving | |
| * canvas.width / .height alone keeps the default 300x150 | |
| * buffer until ResizeObserver fires with real dims. */ | |
| if (!width || !height) return; | |
| 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); | |
| /* When embedded in an iframe, script executes before the | |
| * iframe has done layout, so clientWidth/clientHeight | |
| * are 0 and resizeCanvas() above installs a 0x0 GL | |
| * backbuffer. A plain 'resize' window event never fires | |
| * just because the iframe sized itself, so watch the | |
| * canvas with ResizeObserver and pick up its first real | |
| * size whenever it appears. */ | |
| if (typeof ResizeObserver !== 'undefined') { | |
| new ResizeObserver(function () { | |
| resizeCanvas(); | |
| /* Also dispatch a window resize: emscripten's GLFW | |
| * port listens for that to re-fire its framebuffer | |
| * size callback into wasm, which re-sets glViewport | |
| * and forces a redraw at the correct size. */ | |
| window.dispatchEvent(new Event('resize')); | |
| requestRedraw(); | |
| }).observe(canvas); | |
| } | |
| 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 + location.search; | |
| }); | |
| /* 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); | |
| /* URL parameters: forwarded to C main() as argv so main can | |
| * apply them before first frame (no default-demo flash). */ | |
| var urlParams = new URLSearchParams(location.search); | |
| var moduleArgs = []; | |
| var urlText = urlParams.get('text'); | |
| var urlFont = urlParams.get('font'); | |
| if (urlText) moduleArgs.push('--text=' + urlText); | |
| if (urlFont) moduleArgs.push('--font=' + urlFont); | |
| /* Embed mode: hide the demo's own chrome (font picker, | |
| * text button, backend selector, help overlays) so a host | |
| * page embedding this in an iframe can drive it purely | |
| * through postMessage. The canvas-only view keeps the | |
| * FPS counter and the loading overlay. */ | |
| /* Any presence of ?embed (with or without a value, except | |
| * explicit =0 / =false) turns on embed mode. */ | |
| var embedMode = urlParams.has('embed') && | |
| urlParams.get('embed') !== '0' && | |
| urlParams.get('embed') !== 'false'; | |
| if (embedMode) { | |
| document.documentElement.classList.add('embed'); | |
| } | |
| var Module = { | |
| canvas: canvas, | |
| arguments: moduleArgs, | |
| 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'); | |
| var textOnOpen = ''; | |
| textBtn.addEventListener('click', function () { | |
| var ptr = _web_get_text(); | |
| textOnOpen = UTF8ToString(ptr); | |
| textArea.value = textOnOpen; | |
| 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; } | |
| if (text !== textOnOpen) { | |
| applyText(text); | |
| /* Reflect custom text in the URL so it's copy/pasteable. */ | |
| var url = new URL(location.href); | |
| url.searchParams.set('text', text); | |
| history.replaceState(null, '', url.toString()); | |
| } | |
| 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); | |
| } | |
| function applyText(text) { | |
| var len = lengthBytesUTF8(text) + 1; | |
| var buf = _malloc(len); | |
| stringToUTF8(text, buf, len); | |
| _web_set_text(buf); | |
| _free(buf); | |
| requestRedraw(); | |
| } | |
| function applyFontBytes(bytes) { | |
| var data = new Uint8Array(bytes); | |
| var buf = _malloc(data.length); | |
| HEAPU8.set(data, buf); | |
| _web_load_font(buf, data.length); | |
| _free(buf); | |
| } | |
| function applyVariations(settings) { | |
| var len = lengthBytesUTF8(settings) + 1; | |
| var buf = _malloc(len); | |
| stringToUTF8(settings, buf, len); | |
| _web_set_variations(buf); | |
| _free(buf); | |
| requestRedraw(); | |
| } | |
| function applyFeatures(settings) { | |
| var len = lengthBytesUTF8(settings) + 1; | |
| var buf = _malloc(len); | |
| stringToUTF8(settings, buf, len); | |
| _web_set_features(buf); | |
| _free(buf); | |
| requestRedraw(); | |
| } | |
| function applyPalette(idx) { | |
| _web_set_palette(idx >>> 0); | |
| requestRedraw(); | |
| } | |
| /* Embed-mode message protocol: host page drives the | |
| * demo with { kind: 'text', value } and | |
| * { kind: 'font', bytes: ArrayBuffer }. No origin | |
| * check -- hb-gpu-demo is read-only w.r.t. the host. */ | |
| window.addEventListener('message', function (e) { | |
| var d = e.data; | |
| if (!d || typeof d !== 'object') return; | |
| if (d.kind === 'text' && typeof d.value === 'string') | |
| applyText(d.value); | |
| else if (d.kind === 'font' && d.bytes) | |
| applyFontBytes(d.bytes); | |
| else if (d.kind === 'variations' && typeof d.value === 'string') | |
| applyVariations(d.value); | |
| else if (d.kind === 'palette' && typeof d.value === 'number') | |
| applyPalette(d.value); | |
| else if (d.kind === 'features' && typeof d.value === 'string') | |
| applyFeatures(d.value); | |
| else if (d.kind === 'dark' && typeof d.value === 'boolean') | |
| _web_set_dark(d.value ? 1 : 0); | |
| }); | |
| /* Tell any parent frame we're ready to receive | |
| * messages. Host may have queued them during load. */ | |
| if (window.parent && window.parent !== window) | |
| window.parent.postMessage({ kind: 'ready' }, '*'); | |
| /* ?text= is applied pre-main via Module.arguments; here we | |
| * handle the async ?font= fetch. Text (if any) is re-applied | |
| * after the font loads. | |
| * | |
| * For WebGPU, init_demo is async -- wait for the C side to | |
| * signal readiness before calling exported functions. */ | |
| function startFontFetch () { | |
| if (!urlFont) return; | |
| fetch(urlFont) | |
| .then(function (r) { | |
| if (!r.ok) throw new Error('HTTP ' + r.status); | |
| return r.arrayBuffer(); | |
| }) | |
| .then(function (ab) { | |
| var data = new Uint8Array(ab); | |
| var buf = _malloc(data.length); | |
| HEAPU8.set(data, buf); | |
| _web_load_font(buf, data.length); | |
| _free(buf); | |
| if (urlText) applyText(urlText); | |
| }) | |
| .catch(function (err) { | |
| console.warn('?font= fetch failed: ' + err.message); | |
| }); | |
| } | |
| if (isWebGPU) { | |
| window._webDemoReady = startFontFetch; | |
| } else { | |
| startFontFetch (); | |
| } | |
| } | |
| }; | |
| </script> | |
| {{{ SCRIPT }}} | |
| </body> | |
| </html> |