SOURCE / PINNED RELEASE
Made of little things.
Body Journal
- Release
- e8049f8da4e2…
- Author-recorded commit
- 61c04bf0e8aa…
- License
- LICENSE
- Author’s source reference
- nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-6c4554844c7
Archive hash verified: 4369d778da046a8b…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
type Pointer = Pick<PointerEvent,'pointerId'|'pointerType'|'button'|'buttons'|'clientX'|'clientY'>;
interface GestureActions {
start(event:Pointer):void;
move(event:Pointer):void;
end():void;
hover(event:Pointer):void;
rotate(dx:number,dy:number):void;
zoom(delta:number):void;
}
/** Temporary camera gestures never change the selected editing tool. */
export class SceneGestures {
private touches=new Map<number,Pointer>();
private active?:Pointer;
private pending?:Pointer;
private cameraOnly=false;
private secondary=false;
constructor(private actions:GestureActions) {}
down(e:Pointer):boolean {
if(e.pointerType==='touch') {
this.touches.set(e.pointerId,e);
if(this.touches.size===1 && !this.active) this.pending=e;
if(this.touches.size>=2) {
this.actions.end();this.active=undefined;this.pending=undefined;this.cameraOnly=true;
}
return true;
}
if(e.button!==0 && e.button!==2 || this.touches.size) return false;
this.active=e;this.secondary=e.button===2;
if(!this.secondary) this.actions.start(e);
return true;
}
move(e:Pointer):void {
if(this.touches.has(e.pointerId)) {
const before=[...this.touches.values()].slice(0,2);
this.touches.set(e.pointerId,e);
if(this.cameraOnly) {
if(before.length===2 && before.some(p=>p.pointerId===e.pointerId)) {
const after=[...this.touches.values()].slice(0,2);
const center=(ps:Pointer[])=>[ps.reduce((s,p)=>s+p.clientX,0)/2,ps.reduce((s,p)=>s+p.clientY,0)/2];
const distance=(ps:Pointer[])=>Math.hypot(ps[0].clientX-ps[1].clientX,ps[0].clientY-ps[1].clientY);
const a=center(before),b=center(after);
this.actions.rotate(b[0]-a[0],b[1]-a[1]);
this.actions.zoom((distance(before)-distance(after))*.008);
}
return;
}
// Wait for movement or release before committing a one-finger stroke.
// Landing the second finger therefore does not leave a painted dot.
if(this.pending) {
if(Math.hypot(e.clientX-this.pending.clientX,e.clientY-this.pending.clientY)<6) return;
this.active=this.pending;this.pending=undefined;this.actions.start(this.active);
}
}
if(this.active?.pointerId===e.pointerId) {
if(this.secondary) this.actions.rotate(e.clientX-this.active.clientX,e.clientY-this.active.clientY);
else this.actions.move(e);
this.active=e;
} else if(!this.touches.size) this.actions.hover(e);
}
up(e:Pointer,cancelled=false):void {
if(this.pending?.pointerId===e.pointerId && !cancelled && !this.cameraOnly) this.actions.start(this.pending);
if(this.active?.pointerId===e.pointerId || this.pending?.pointerId===e.pointerId) {
this.actions.end();this.active=undefined;this.pending=undefined;this.secondary=false;
}
this.touches.delete(e.pointerId);
// Keep the remaining finger inert after a two-finger gesture until lifted.
if(!this.touches.size) this.cameraOnly=false;
}
reset():void {
this.actions.end();this.active=undefined;this.pending=undefined;
this.touches.clear();this.cameraOnly=false;this.secondary=false;
}
wheel(e:Pick<WheelEvent,'deltaX'|'deltaY'|'deltaMode'|'ctrlKey'|'shiftKey'>):void {
const unit=e.deltaMode===1?16:e.deltaMode===2?400:1;
// Trackpad two-finger scrolling, mouse wheel and native pinch all zoom.
// Two-finger click-drag arrives as a secondary mouse drag and rotates.
this.actions.zoom(e.deltaY*unit*.002);
}
}
