Back to Body Journal
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.

src/scene-gestures.ts
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);
  }
}