Files
8bitMyGoUI/web/main.js
T

232 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Feeds browser input to the Go gallery; blits its frames.
'use strict'
const screen = document.getElementById('screen')
const ctx = screen.getContext('2d')
const ime = document.getElementById('ime')
const blocks = [...document.querySelectorAll('#bar i')]
const params = new URLSearchParams(location.search)
const mac = /Mac|iPhone|iPad/.test(navigator.platform)
const cursors = ['default', 'pointer', 'text', 'move', 'ew-resize', 'ns-resize', 'nwse-resize', 'nesw-resize',
'not-allowed', 'crosshair', 'grab', 'grabbing', 'n-resize', 'e-resize', 's-resize', 'w-resize', 'col-resize',
'row-resize', 'vertical-text', 'copy', 'alias', 'context-menu', 'none']
const SHIFT = 1, CTRL = 2, ALT = 4, SUPER = 8
const log = []
const asked = params.get('zoom')
const fixed = /^[1-9]$/.test(asked) ? Number(asked) : 0
let api, zoom, scale, timer, first, active = false, skipText = ''
function note (msg) {
log.push(msg)
console.info('8bit:', msg)
}
window.__8bitCrash = (msg) => {
console.error('8bit: crash', msg)
clearTimeout(timer)
document.getElementById('why').textContent = msg
document.getElementById('over').hidden = false
}
document.getElementById('continue').onclick = () => location.reload()
if (asked !== null && !fixed) note(`ignored zoom=${asked}: use a whole number 1–9`)
// size picks device pixels per dot; returns window in dots.
function size () {
zoom = fixed || Math.max(1, Math.round(devicePixelRatio * (innerWidth >= 1000 ? 2 : 1)))
scale = zoom / devicePixelRatio
return [Math.max(1, Math.floor(innerWidth / scale)), Math.max(1, Math.floor(innerHeight / scale))]
}
function hashPath () { return decodeURIComponent(location.hash.slice(1)) }
// show blits a frame and acts on Go's requests.
function show (r) {
if (!r) return
if (screen.width !== r.w || screen.height !== r.h) {
screen.width = r.w
screen.height = r.h
}
screen.style.width = r.w * scale + 'px'
screen.style.height = r.h * scale + 'px'
ctx.putImageData(new ImageData(r.pix, r.w, r.h), 0, 0)
if (r.path && '#' + r.path !== location.hash && !(r.path === '/' && location.hash === '')) {
history.pushState(null, '', '#' + r.path)
}
screen.style.cursor = cursors[r.cursor]
active = r.caret !== undefined
if (active) {
ime.style.left = r.caret[0] * scale + 'px'
ime.style.top = r.caret[1] * scale + 'px'
ime.style.height = r.caret[2] * scale + 'px'
}
if (r.copied !== undefined && navigator.clipboard) navigator.clipboard.writeText(r.copied).catch(e => note('clipboard: ' + e))
for (const url of r.open || []) window.open(url, '_blank', 'noopener')
clearTimeout(timer)
if (r.due !== undefined && !document.hidden) timer = setTimeout(() => show(api.frame()), Math.max(0, r.due))
}
function mods (e) {
const m = (e.shiftKey ? SHIFT : 0) | (e.altKey ? ALT : 0) | (e.ctrlKey ? CTRL : 0) | (e.metaKey ? SUPER : 0)
// wasm's ui.Cmd is Ctrl; ⌘ stays Super for line edits.
if (mac && e.metaKey && !/^(Arrow|Backspace|Delete)/.test(e.code)) return (m & ~SUPER) | CTRL
return m
}
function at (e) {
const r = screen.getBoundingClientRect()
return [(e.clientX - r.left) / scale, (e.clientY - r.top) / scale]
}
// Taps click; held fingers drag; moving fingers scroll.
let touch = null
screen.addEventListener('pointerdown', (e) => {
e.preventDefault()
if (!e.isPrimary) return
ime.focus({ preventScroll: true })
screen.setPointerCapture(e.pointerId)
const [x, y] = at(e)
if (e.pointerType === 'touch') {
const t = touch = { x, y, drag: false, scroll: false }
t.hold = setTimeout(() => { t.drag = true; show(api.pointer('down', x, y)) }, 400)
} else if (e.button === 0) show(api.pointer('down', x, y))
else if (e.button === 2) show(api.pointer('right', x, y))
})
screen.addEventListener('pointermove', (e) => {
if (!e.isPrimary) return
const [x, y] = at(e)
if (e.pointerType !== 'touch' || touch?.drag) return show(api.pointer('move', x, y))
if (!touch || (!touch.scroll && Math.hypot(x - touch.x, y - touch.y) * scale <= 8)) return
clearTimeout(touch.hold)
touch.scroll = true
show(api.wheel(x, y, touch.x - x, touch.y - y))
touch.x = x
touch.y = y
})
screen.addEventListener('pointerup', (e) => {
if (!e.isPrimary) return
const [x, y] = at(e)
if (e.pointerType !== 'touch') {
if (e.button === 0) show(api.pointer('up', x, y))
return
}
if (!touch) return
clearTimeout(touch.hold)
if (touch.drag) show(api.pointer('up', x, y))
else if (!touch.scroll) {
api.pointer('down', x, y)
show(api.pointer('up', x, y))
}
touch = null
})
// A release outside the window cancels the press.
screen.addEventListener('pointercancel', (e) => {
if (!touch || !e.isPrimary) return
clearTimeout(touch.hold)
if (touch.drag) show(api.pointer('up', -1, -1))
touch = null
})
screen.addEventListener('contextmenu', (e) => e.preventDefault())
screen.addEventListener('wheel', (e) => {
e.preventDefault()
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? innerHeight : 1
const [x, y] = at(e)
show(api.wheel(x, y, e.deltaX * unit / scale, e.deltaY * unit / scale))
}, { passive: false })
const keep = new Set(['Tab', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End',
'Backspace', 'Delete', 'Enter', 'NumpadEnter', 'Escape'])
window.addEventListener('keydown', (e) => {
if (e.isComposing || !api) return
const m = mods(e)
const cmd = (m & CTRL) && !(m & ALT)
if (cmd && e.code === 'KeyV') return
// Like native hosts: text only to fields focused at keydown.
const typing = active
show(api.key(e.code, m))
if (keep.has(e.code) || (!typing && e.key.length === 1)) e.preventDefault()
})
ime.addEventListener('input', (e) => {
if (e.isComposing) return
const text = e.data || ''
ime.value = ''
if (!e.inputType.startsWith('insert') || e.inputType === 'insertLineBreak' || e.inputType === 'insertFromPaste') return
if (text === skipText) {
skipText = ''
return
}
if (text) show(api.text(text))
})
ime.addEventListener('compositionupdate', (e) => show(api.compose(e.data, [...e.data].length)))
ime.addEventListener('compositionend', (e) => {
api.compose('', 0)
skipText = e.data
setTimeout(() => { skipText = '' }, 0)
ime.value = ''
show(api.text(e.data))
})
ime.addEventListener('paste', (e) => {
e.preventDefault()
show(api.paste(e.clipboardData.getData('text/plain')))
})
window.addEventListener('focus', () => api && show(api.focus(true)))
window.addEventListener('blur', () => api && show(api.focus(false)))
window.addEventListener('popstate', () => api && show(api.go(hashPath() || '/')))
document.addEventListener('visibilitychange', () => api && !document.hidden && show(api.frame()))
let resizing
window.addEventListener('resize', () => {
clearTimeout(resizing)
resizing = setTimeout(() => {
if (!api) return
const [w, h] = size()
show(api.resize(w, h))
}, 50)
})
// load streams the wasm, filling the bar as bytes arrive.
async function load () {
const res = await fetch('__WASM__')
if (!res.ok) throw new Error(`${res.url}: HTTP ${res.status}`)
const total = Number('__SIZE__')
const reader = res.body.getReader()
const parts = []
let got = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
parts.push(value)
got += value.length
const lit = Math.min(blocks.length, Math.floor(got / total * blocks.length))
blocks.forEach((b, i) => b.classList.toggle('on', i < lit))
document.getElementById('loading').setAttribute('aria-valuenow', lit)
}
const bytes = new Uint8Array(got)
let off = 0
for (const p of parts) {
bytes.set(p, off)
off += p.length
}
const go = new Go()
const { instance } = await WebAssembly.instantiate(bytes, go.importObject)
const t0 = performance.now()
go.run(instance).then(() => window.__8bitCrash('The Go program exited.'))
api = window.__8bitGo
if (!api) throw new Error('Go did not export __8bitGo')
note(`wasm ${(got / 1e6).toFixed(1)} MB loaded`)
const [w, h] = size()
show(api.start(w, h, hashPath()))
first = performance.now() - t0
document.getElementById('loading').remove()
ime.focus({ preventScroll: true })
window.__8bit = {
ready: true,
stats: () => ({ ...api.stats(), zoom, first }),
texts: () => api.texts(),
page: () => api.stats().path,
find: (label) => api.find(label)?.map(v => v * scale),
log
}
note(`ready ${w}x${h} dots, ${zoom} pixels per dot, first frame ${first.toFixed(0)} ms`)
}
load().catch((err) => window.__8bitCrash(String(err && err.stack || err)))