Skip to content

Commit

Permalink
Refactor user interactions. Fixes #2 (#9)
Browse files Browse the repository at this point in the history
Refactored all interactions to use pointer events instead of mouse and
touch events. Implemented pinch zooming for multi-touch enabled
devices and re-implemented pan support for pointer events.

The `EventListener` class has been refactored to allow defining an
element
per event. It also allows defining options on initialization or when
adding events.

Events specific to Puzzle were moved out of index and into the Puzzle
class. Other miscellaneous interactions were better organized into other
files.

Modifier selection now provides haptic feedback on touch devices. The
concept of toggling a modifier has been introduced, which can allow for
the toggling of modifier state based on interaction. For example, middle
clicking on a modifier with a mouse will toggle it as will triggering a
pointer down on a modifier followed by a pointer leave. For now, this
behavior is only used to toggle the rotation direction of the rotate
modifier, but it could be used for other things in the future.

The header/footer drop-shadows now correctly always overlay the canvas
area.
  • Loading branch information
kflorence authored Jan 24, 2024
1 parent cd9aca8 commit 4e000f3
Show file tree
Hide file tree
Showing 26 changed files with 537 additions and 403 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 19 additions & 2 deletions src/components/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export class Cache {
keys.forEach((key) => { this.#cache[key] = new Cache() })
}

add (key, item) {
set (key, item) {
if (this.#hasKeys && !this.#keys.includes(key)) {
throw new Error(`Invalid key: ${key}`)
}
Expand All @@ -22,7 +22,24 @@ export class Cache {
return key ? this.#cache[key] : this.#cache
}

remove (key) {
keys (key) {
return Object.keys(this.#get(key))
}

length (key) {
return this.keys(key).length
}

unset (key) {
delete this.#cache[key]
}

values (key) {
return Object.values(this.#get(key))
}

#get (key) {
const value = this.get(key)
return value instanceof Cache ? value.get() : value
}
}
11 changes: 11 additions & 0 deletions src/components/debug.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { params } from './util'

const console = window.console = window.console || { debug: function () {} }
const consoleDebug = console.debug

export function debug (debug) {
console.debug = debug ? consoleDebug : function () {}
}

// Silence debug logging by default
debug(params.has('debug') ?? false)
30 changes: 0 additions & 30 deletions src/components/eventListener.js

This file was deleted.

27 changes: 27 additions & 0 deletions src/components/eventListeners.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export class EventListeners {
#events = []
#options = { element: document }

constructor (options = {}) {
this.#options = Object.assign(this.#options, options)
}

add (events, options = {}) {
this.#events = this.#events.concat(events.map((event) => {
event = Object.assign({}, this.#options, options, event)
if (!event.type) {
throw new Error('Event type is required')
}
if (event.context) {
event.handler = event.handler.bind(event.context)
}
event.element.addEventListener(event.type, event.handler, event.options)
return event
}))
}

remove () {
this.#events.forEach((event) => event.element.removeEventListener(event.type, event.handler))
this.#events = []
}
}
14 changes: 14 additions & 0 deletions src/components/feedback.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Puzzle } from './puzzle'

const container = document.getElementById('feedback-container')
const help = document.getElementById('help')

document.getElementById('feedback').addEventListener('click', () => {
help.setAttribute('open', 'true')
container.scrollIntoView(true)
})

const doorbellOptions = window.doorbellOptions
document.addEventListener(Puzzle.Events.Updated, (event) => {
doorbellOptions.properties.puzzleId = event.detail.state.getId()
})
6 changes: 6 additions & 0 deletions src/components/infoDialog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const dialog = document.getElementById('dialog')
document.getElementById('info').addEventListener('click', () => {
if (!dialog.open) {
dialog.showModal()
}
})
177 changes: 177 additions & 0 deletions src/components/interact.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import paper, { Point } from 'paper'
import { Cache } from './cache'
import { EventListeners } from './eventListeners'

export class Interact {
#bounds
#cache = new Cache(Object.values(Interact.CacheKeys))
#element
#eventListener = new EventListeners({ context: this })
#offset

constructor (element) {
this.#bounds = element.getBoundingClientRect()
this.#element = element
this.#offset = new Point(this.#bounds.left, this.#bounds.top)
this.#eventListener.add([
{ type: 'pointercancel', handler: this.onPointerUp },
{ type: 'pointerdown', handler: this.onPointerDown },
{ type: 'pointerleave', handler: this.onPointerUp },
{ type: 'pointermove', handler: this.onPointerMove },
{ type: 'pointerout', handler: this.onPointerUp },
{ type: 'pointerup', handler: this.onPointerUp },
{ type: 'wheel', handler: this.onMouseWheel, options: { passive: false } }
], { element })
}

onMouseWheel (event) {
event.preventDefault()
this.#zoom(new Point(event.offsetX, event.offsetY), event.deltaY, 1.05)
}

onPan (event) {
const point = this.#getPoint(event)
const pan = this.#getGesture(Interact.GestureKeys.Pan)

if (!pan) {
this.#setGesture(Interact.GestureKeys.Pan, { from: point })
return
}

const center = pan.from.subtract(point).add(paper.view.center)

// Allow a little wiggle room to prevent panning on tap
if (paper.view.center.subtract(center).length > 1) {
if (!document.body.classList.contains('grab')) {
document.body.classList.add('grab')
}

// Center on the cursor
paper.view.center = center
}
}

onPinch (events) {
const pointer0 = events[0]
const pointer1 = events[1]

const delta = Math.abs(pointer0.clientX - pointer1.clientX)
const pinch = this.#getGesture(Interact.GestureKeys.Pinch)

if (!pinch) {
const point0 = new Point(pointer0.clientX, pointer0.clientY)
const point1 = new Point(pointer1.clientX, pointer1.clientY)
const vector = point1.subtract(point0).divide(2)
const center = point1.subtract(vector).subtract(this.#offset)
this.#setGesture(Interact.GestureKeys.Pinch, { center, delta })
return
}

if (delta > 1) {
this.#zoom(pinch.center, pinch.delta - delta, 1.01)
}

pinch.delta = delta
}

onPointerDown (event) {
this.#cache.get(Interact.CacheKeys.Down).set(event.pointerId, event)
}

onPointerMove (event) {
const down = this.#cache.get(Interact.CacheKeys.Down).get(event.pointerId)
if (!down) {
// Ignore events until there is a pointer down event
return
}

// For some reason pointermove fires on mobile even if there was no movement
const diff = this.#getPoint(event).subtract(this.#getPoint(down)).length
if (diff > 1) {
this.#cache.get(Interact.CacheKeys.Move).set(event.pointerId, event)

const events = this.#cache.get(Interact.CacheKeys.Move).values()
if (events.length === 2) {
this.onPinch(events)
} else {
this.onPan(event)
}
}
}

onPointerUp (event) {
const down = this.#cache.get(Interact.CacheKeys.Down).get(event.pointerId)
if (!down) {
return
}

if (
this.#cache.length(Interact.CacheKeys.Down) === 1 &&
!this.#cache.get(Interact.CacheKeys.Move).get(event.pointerId)
) {
this.onTap(down)
}

document.body.classList.remove('grab')

this.#cache.get(Interact.CacheKeys.Down).unset(event.pointerId)
this.#cache.get(Interact.CacheKeys.Move).unset(event.pointerId)
this.#cache.get(Interact.CacheKeys.Gesture).unset(Interact.GestureKeys.Pan)

if (this.#cache.length(Interact.CacheKeys.Move) < 2) {
this.#cache.get(Interact.CacheKeys.Gesture).unset(Interact.GestureKeys.Pinch)
}
}

onTap (event) {
const point = this.#getPoint(event)
this.#element.dispatchEvent(new CustomEvent(Interact.GestureKeys.Tap, { detail: { event, point } }))
}

#getGesture (key) {
return this.#cache.get(Interact.CacheKeys.Gesture).get(key)
}

#getPoint (event) {
return paper.view.viewToProject(new Point(event.clientX, event.clientY).subtract(this.#offset))
}

#setGesture (key, value) {
this.#cache.get(Interact.CacheKeys.Gesture).set(key, value)
}

#zoom (point, delta, factor) {
const zoom = delta < 0 ? paper.view.zoom * factor : paper.view.zoom / factor

// Don't allow zooming too far in or out
if (zoom > 2 || zoom < 0.5) {
return
}

// Convert the touch point from the view coordinate space to the project coordinate space
const touchPoint = paper.view.viewToProject(point)
const touchOffset = touchPoint.subtract(paper.view.center)

// Adjust center towards cursor location
const zoomOffset = touchPoint
.subtract(touchOffset.multiply(paper.view.zoom / zoom))
.subtract(paper.view.center)

paper.view.zoom = zoom
paper.view.center = paper.view.center.add(zoomOffset)
}

static CacheKeys = Object.freeze({
Down: 'down',
Move: 'move',
Gesture: 'gesture'
})

static GestureKeys = Object.freeze({
Pan: 'pan',
Pinch: 'pinch',
Tap: 'tap'
})

static vibratePattern = 25
}
2 changes: 1 addition & 1 deletion src/components/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export class Item extends Stateful {
return this.group.parent
}

onClick () {}
onTap () {}

onCollision ({ collisionStep }) {
return collisionStep
Expand Down
4 changes: 2 additions & 2 deletions src/components/items/beam.js
Original file line number Diff line number Diff line change
Expand Up @@ -325,10 +325,10 @@ export class Beam extends Item {
this.addStep(step.copy({
colors: mergeWith.colors,
onAdd: () => {
this.#cache.get(Beam.CacheKeys.MergeWith).add(beam.id, mergeWith)
this.#cache.get(Beam.CacheKeys.MergeWith).set(beam.id, mergeWith)
},
onRemove: () => {
this.#cache.get(Beam.CacheKeys.MergeWith).remove(beam.id)
this.#cache.get(Beam.CacheKeys.MergeWith).unset(beam.id)
},
state: step.state.copy(new StepState.MergeWith(mergeWith))
}))
Expand Down
2 changes: 1 addition & 1 deletion src/components/items/portal.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export class Portal extends movable(rotatable(Item)) {
},
{
beam,
onClick: (puzzle, tile) => {
onTap: (puzzle, tile) => {
const destination = destinations.find((portal) => portal.parent === tile)
if (destination) {
beam.addStep(this.#step(destination, nextStep, portalState))
Expand Down
4 changes: 2 additions & 2 deletions src/components/items/tile.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ export class Tile extends Item {
return state
}

onClick (event) {
onTap (event) {
console.debug(this.coordinates.offset.toString(), this)
this.items.forEach((item) => item.onClick(event))
this.items.forEach((item) => item.onTap(event))
}

onDeselected (selectedTile) {
Expand Down
Loading

0 comments on commit 4e000f3

Please sign in to comment.