Files
portal/gesture.js
T
Bendik Aagaard LynghaugandClaude Fable 5 0def91f8ab
Deploy / deploy (push) Successful in 1m5s
Echo thumbnails fade in via timeout, not rAF
The 'shown' class only needs a later tick for the opacity transition
to run - requestAnimationFrame never fires in headless engines
(caught in hwatu verification), and a timeout is equivalent in real
browsers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:16:53 +02:00

336 lines
12 KiB
JavaScript
Raw Permalink 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.
// The `type: gesture` requirement widget: draw one stroke on a canvas,
// and the stroke becomes the field's value. Mirrors the two house
// patterns at once:
//
// - prosekit-editor.js's hidden-input bridge: the widget writes JSON
// into a paired hidden <input> and fires a real bubbling `input`
// event, so the Rust side reuses the exact same RwSignal/on:input
// wiring every other field kind uses - no bespoke value channel.
// - yes.js's lifecycle: mountGesture returns a handle whose stop()
// tears everything down (socket, listeners, rAF), because SPA
// navigation never unloads the document.
//
// When a relay URL is given, the widget also speaks the redoal-relay
// protocol (ADR-0013 in the redoal repo): announce the stroke, receive
// an ack carrying the stroke's gesture key + the key's own decoded
// path ("what the network heard", drawn as a ghost), and receive
// echoes of similar strokes other visitors drew, shown as thumbnails.
// Everything network is best-effort: no relay, refused connection, or
// a dropped socket all degrade to a plain offline drawing input.
//
// Canvas colors can't come from CSS custom properties (same
// constraint yes.js documents), so the widget carries its own copy of
// the palette for both themes - any palette change must land here AND
// in style/main.css.
const THEMES = {
dark: {
stroke: '#8ec2c0',
ghost: 'rgba(255, 255, 255, 0.28)',
echo: '#8ec2c0',
},
light: {
stroke: '#47807e',
ghost: 'rgba(29, 29, 27, 0.30)',
echo: '#47807e',
},
};
const MIN_POINT_DISTANCE = 3; // css px between recorded points
const MAX_ECHOES = 8;
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000;
class GestureWidget {
constructor(container, hidden, relayUrl, hero) {
this.container = container;
this.hidden = hidden || null;
this.relayUrl = relayUrl || '';
this.hero = !!hero;
this.points = [];
this.ghost = null;
this.ambient = []; // hero mode: echoed strokes drawn in place
this.drawing = false;
this.stopped = false;
this.ws = null;
this.reconnectDelay = RECONNECT_BASE_MS;
this.reconnectTimer = null;
this.pendingAnnounce = null;
this.canvas = document.createElement('canvas');
this.canvas.className = 'gesture-canvas';
container.appendChild(this.canvas);
if (!this.hero) {
this.echoes = document.createElement('div');
this.echoes.className = 'gesture-echoes';
container.appendChild(this.echoes);
}
if (this.relayUrl) {
this.status = document.createElement('span');
this.status.className = 'gesture-status offline';
this.status.setAttribute('aria-label', 'relay connection');
container.appendChild(this.status);
}
this._themeQuery = window.matchMedia('(prefers-color-scheme: light)');
this._onTheme = () => this.render();
this._themeQuery.addEventListener('change', this._onTheme);
this._onResize = () => { this.sizeCanvas(); this.render(); };
window.addEventListener('resize', this._onResize);
this.sizeCanvas();
this._onDown = (e) => this.beginStroke(e);
this._onMove = (e) => this.extendStroke(e);
this._onUp = (e) => this.endStroke(e);
this.canvas.addEventListener('pointerdown', this._onDown);
this.canvas.addEventListener('pointermove', this._onMove);
this.canvas.addEventListener('pointerup', this._onUp);
this.canvas.addEventListener('pointercancel', this._onUp);
if (this.relayUrl) this.connect();
}
theme() {
return this._themeQuery.matches ? THEMES.light : THEMES.dark;
}
sizeCanvas() {
const rect = this.canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.canvas.width = Math.max(1, Math.round(rect.width * dpr));
this.canvas.height = Math.max(1, Math.round(rect.height * dpr));
this.ctx = this.canvas.getContext('2d');
this.ctx.scale(dpr, dpr);
this.cssWidth = rect.width;
this.cssHeight = rect.height;
}
pos(e) {
const rect = this.canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
beginStroke(e) {
e.preventDefault();
this.canvas.setPointerCapture(e.pointerId);
// One stroke only - a new pointerdown replaces the old drawing
// (and any ghost/ambient trails from the previous round).
this.drawing = true;
this.points = [this.pos(e)];
this.ghost = null;
this.ambient = [];
this.render();
}
extendStroke(e) {
if (!this.drawing) return;
const p = this.pos(e);
const last = this.points[this.points.length - 1];
const dx = p.x - last.x, dy = p.y - last.y;
if (Math.sqrt(dx * dx + dy * dy) < MIN_POINT_DISTANCE) return;
this.points.push(p);
this.render();
}
endStroke() {
if (!this.drawing) return;
this.drawing = false;
if (this.points.length < 2) return;
const normalized = this.normalized();
this.setValue({ points: normalized, key: null });
this.announce(normalized);
}
// Canvas-frame normalization into the -1..1 square (the relay
// re-normalizes around the stroke itself before keying - this
// just bounds the payload and keeps the stored value
// resolution-independent).
normalized() {
const half = Math.max(this.cssWidth, this.cssHeight) / 2;
const cx = this.cssWidth / 2, cy = this.cssHeight / 2;
return this.points.map((p) => [
Math.round(((p.x - cx) / half) * 10000) / 10000,
Math.round(((p.y - cy) / half) * 10000) / 10000,
]);
}
setValue(value) {
if (!this.hidden) return;
this.hidden.value = JSON.stringify(value);
this.hidden.dispatchEvent(new Event('input', { bubbles: true }));
}
// ── Relay ──────────────────────────────────────────────────────
connect() {
if (this.stopped) return;
let ws;
try {
ws = new WebSocket(this.relayUrl);
} catch {
this.scheduleReconnect();
return;
}
this.ws = ws;
ws.addEventListener('open', () => {
if (this.stopped) return;
this.reconnectDelay = RECONNECT_BASE_MS;
this.status?.classList.replace('offline', 'live');
if (this.pendingAnnounce) {
ws.send(JSON.stringify({ type: 'announce', points: this.pendingAnnounce }));
this.pendingAnnounce = null;
}
});
ws.addEventListener('message', (e) => this.onMessage(e));
ws.addEventListener('close', () => {
this.status?.classList.replace('live', 'offline');
this.ws = null;
this.scheduleReconnect();
});
// 'error' is always followed by 'close'; nothing to do here.
}
scheduleReconnect() {
if (this.stopped || this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
}
announce(points) {
if (!this.relayUrl) return;
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'announce', points }));
} else {
this.pendingAnnounce = points;
}
}
onMessage(e) {
let msg;
try {
msg = JSON.parse(e.data);
} catch {
return;
}
if (msg.type === 'ack') {
if (this.points.length >= 2) {
this.setValue({ points: this.normalized(), key: msg.key });
}
this.ghost = msg.path;
this.render();
} else if (msg.type === 'echo') {
if (this.hero) {
this.ambient.push(msg.path);
if (this.ambient.length > MAX_ECHOES) this.ambient.shift();
this.render();
} else {
this.addEchoThumbnail(msg);
}
}
// 'error' frames are intentionally silent: the widget is a
// form field first, and a rate-limited announce shouldn't
// alarm anyone mid-form.
}
addEchoThumbnail(msg) {
const thumb = document.createElement('canvas');
thumb.className = 'gesture-echo';
// Closer strokes render stronger; 500 is comfortably past the
// relay's default threshold, so everything stays visible.
const strength = Math.max(0.35, Math.min(1, 1 - msg.distance / 500));
thumb.style.setProperty('--echo-strength', String(strength));
this.echoes.prepend(thumb);
while (this.echoes.children.length > MAX_ECHOES) {
this.echoes.lastChild.remove();
}
const dpr = window.devicePixelRatio || 1;
const rect = thumb.getBoundingClientRect();
thumb.width = Math.max(1, Math.round(rect.width * dpr));
thumb.height = Math.max(1, Math.round(rect.height * dpr));
const ctx = thumb.getContext('2d');
ctx.scale(dpr, dpr);
this.drawPath(ctx, msg.path, rect.width, rect.height, this.theme().echo, 1.5, 0.15);
// A timeout, not requestAnimationFrame: the class must land on
// a later tick for the opacity transition to run, and rAF
// doesn't tick at all in headless engines.
setTimeout(() => thumb.classList.add('shown'), 30);
}
// ── Rendering ──────────────────────────────────────────────────
// Draw a normalized (-1..1) path fitted into w×h with padding.
drawPath(ctx, path, w, h, color, width, pad) {
if (!path || path.length < 2) return;
const scale = (Math.min(w, h) / 2) * (1 - pad);
const cx = w / 2, cy = h / 2;
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
path.forEach(([x, y], i) => {
const px = cx + x * scale, py = cy + y * scale;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
}
render() {
if (!this.ctx) return;
const t = this.theme();
this.ctx.clearRect(0, 0, this.cssWidth, this.cssHeight);
for (const path of this.ambient) {
this.ctx.globalAlpha = 0.25;
this.drawPath(this.ctx, path, this.cssWidth, this.cssHeight, t.echo, 1.5, 0.2);
}
this.ctx.globalAlpha = 1;
if (this.ghost) {
this.drawPath(this.ctx, this.ghost, this.cssWidth, this.cssHeight, t.ghost, 2, 0.2);
}
if (this.points.length >= 1) {
this.ctx.strokeStyle = t.stroke;
this.ctx.lineWidth = 2;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
this.ctx.beginPath();
this.points.forEach((p, i) => {
if (i === 0) this.ctx.moveTo(p.x, p.y);
else this.ctx.lineTo(p.x, p.y);
});
this.ctx.stroke();
}
}
stop() {
this.stopped = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.ws) {
// The close event still fires, but scheduleReconnect
// no-ops once stopped is set.
try { this.ws.close(); } catch { /* already dead */ }
this.ws = null;
}
window.removeEventListener('resize', this._onResize);
this._themeQuery.removeEventListener('change', this._onTheme);
this.canvas.removeEventListener('pointerdown', this._onDown);
this.canvas.removeEventListener('pointermove', this._onMove);
this.canvas.removeEventListener('pointerup', this._onUp);
this.canvas.removeEventListener('pointercancel', this._onUp);
}
}
export function mountGesture(container, hidden, relayUrl) {
return new GestureWidget(container, hidden, relayUrl, false);
}
// Hero variant: no form field to mirror into, and echoes render as
// ambient strokes on the drawing canvas itself instead of thumbnails.
export function mountGestureHero(container, relayUrl) {
return new GestureWidget(container, null, relayUrl, true);
}