Skip to content

Commit

Permalink
main: gestureland?
Browse files Browse the repository at this point in the history
  • Loading branch information
SomeHats committed Mar 7, 2024
1 parent 0121a36 commit 21229c8
Show file tree
Hide file tree
Showing 11 changed files with 770 additions and 1 deletion.
103 changes: 103 additions & 0 deletions src/gestureland/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Gestureland } from "@/gestureland/Gestureland";
import { Stroke } from "@/gestureland/GesturelandStore";
import { DebugPointX } from "@/lib/DebugSvg";
import { Ticker } from "@/lib/Ticker";
import { Vector2 } from "@/lib/geom/Vector2";
import {
sizeFromContentRect,
useResizeObserver,
} from "@/lib/hooks/useResizeObserver";
import { SvgPathBuilder } from "@/lib/svgPathBuilder";
import { compact } from "@/lib/utils";
import { track } from "@tldraw/state";
import { useEffect, useLayoutEffect, useMemo, useState } from "react";

export function AppWrapper() {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const size = useResizeObserver(container, sizeFromContentRect);

return (
<div ref={setContainer} className="fixed inset-0 touch-none">
{size && container && <App size={size} container={container} />}
</div>
);
}

const App = track(function App({
size,
container,
}: {
size: Vector2;
container: HTMLDivElement;
}) {
const app = useMemo(() => new Gestureland(container), [container]);

useLayoutEffect(() => {
app.viewportSize = size;
}, [app, size]);

useEffect(() => {
const ticker = new Ticker();
const unsubscribeTicker = ticker.listen(() => app.onTick());
ticker.start();
container.addEventListener("wheel", app.onWheel, {
passive: false,
capture: true,
});
container.addEventListener("pointerdown", app.onPointerDown);
container.addEventListener("pointermove", app.onPointerMove);
container.addEventListener("pointerup", app.onPointerUp);
container.addEventListener("pointercancel", app.onPointerCancel);

return () => {
unsubscribeTicker();
ticker.stop();
container.removeEventListener("wheel", app.onWheel, {
capture: true,
});
container.removeEventListener("pointerdown", app.onPointerDown);
container.removeEventListener("pointermove", app.onPointerMove);
container.removeEventListener("pointerup", app.onPointerUp);
container.removeEventListener("pointercancel", app.onPointerCancel);
};
}, [app, container]);

return (
<svg viewBox={`0 0 ${size.x} ${size.y}`} width={size.x} height={size.y}>
<g transform={app.pageToViewport.toCss()}>
{app.shapes.map((s) => (
<Shape key={s.id} shape={s} />
))}
</g>
{Array.from(app.pointers.values(), (p) => (
<DebugPointX
key={p.pointerId}
position={p.viewportPosition}
label={compact([
p.pointerId,
p.type,
p.isDown ? "down" : null,
]).join(", ")}
/>
))}
</svg>
);
});

const Shape = track(function Shape({ shape }: { shape: Stroke }) {
if (!shape.points.length) return null;
const path = new SvgPathBuilder().moveTo(shape.points[0]);
for (const point of shape.points) {
path.lineTo(point);
}
return (
<path
d={path.toString()}
stroke="black"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
);
});
290 changes: 290 additions & 0 deletions src/gestureland/Gestureland.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
import {
CAMERA_ID,
Camera,
GesturelandStore,
Stroke,
} from "@/gestureland/GesturelandStore";
import { EXPIRE_POINTER_AFTER_MS } from "@/gestureland/constants";
import {
drawOnCanvas,
interactWithShapes,
} from "@/gestureland/interactions/interactions";
import {
GLGestureType,
GLPointer,
GLPointerEvent,
GLPointerId,
GLTarget,
GLTargetFn,
PointerState,
} from "@/gestureland/types";
import { assert, assertExists } from "@/lib/assert";
import { Transform2d } from "@/lib/geom/Transform2d";
import { Vector2 } from "@/lib/geom/Vector2";
import { memo, reactive } from "@/lib/signia";
import { exhaustiveSwitchError, groupBy, sortBy } from "@/lib/utils";

export class Gestureland {
readonly store: GesturelandStore;

constructor(readonly container: HTMLElement) {
this.store = new GesturelandStore();
this.store.put1(Camera.create({ id: CAMERA_ID }));

this.addTarget(drawOnCanvas);
this.addTarget(interactWithShapes);
}

// #region ======== CAMERA & VIEWPORT ========
@reactive accessor viewportSize: Vector2 = Vector2.UNIT;

@memo get pageToViewport() {
return Transform2d.translate(this.viewportSize.scale(0.5))
.scaleUniform(this.camera.zoom)
.translate(Vector2.from(this.camera.center).scale(-1));
}
@memo get viewportToPage() {
return this.pageToViewport.invert();
}

@memo get camera() {
return assertExists(this.store.get(CAMERA_ID));
}
set camera(newCamera: Camera) {
assert(newCamera.id === CAMERA_ID);
this.store.put([newCamera]);
}
// #endregion

// #region ======== SHAPES ========
@memo get shapes() {
return this.store
.allRecords()
.filter((v): v is Stroke => v.typeName === "stroke");
}
// #endregion

// #region ======== TARGETS ========
@reactive private accessor targetFns: readonly GLTargetFn[] = [];
addTarget(targetFn: GLTargetFn) {
this.targetFns = [...this.targetFns, targetFn];
}

@memo get targets() {
const result = [];
for (const fn of this.targetFns) {
const targets = fn(this);
if (!targets) continue;
if (Array.isArray(targets)) {
result.push(...targets);
} else {
result.push(targets);
}
}
return sortBy(result, (target) => target.index);
}
@memo get targetsByIndex() {
return groupBy(this.targets, (target) => target.index);
}

getTargetAtPoint(
pagePoint: Vector2,
{
sensitivity,
filter = () => true,
}: { sensitivity: number; filter?: (target: GLTarget) => boolean },
) {
const hoverDistance = sensitivity * this.camera.zoom;

for (const [_index, targets] of sortBy(
[...this.targetsByIndex],
([index]) => index,
).reverse()) {
let lowestDistance = hoverDistance;
let found = null;

for (const target of targets) {
if (!filter(target)) continue;
const distance = target.distanceTo(pagePoint);
if (distance <= lowestDistance) {
lowestDistance = distance;
found = target;
}
}

if (found) return found;
}

return null;
}
@memo get enabledGestures() {
const enabled: Record<GLGestureType, boolean> = {
drag: false,
twoFingerDrag: false,
tap: false,
doubleTap: false,
};

for (const target of this.targets) {
if (target.onDrag) enabled.drag = true;
if (target.onTwoFingerDrag) enabled.twoFingerDrag = true;
if (target.onTap) enabled.tap = true;
if (target.onDoubleTap) enabled.doubleTap = true;
}

return enabled;
}
// #endregion

// #region ======== EVENTS ========
onWheel = (event: WheelEvent) => {
event.preventDefault();
if (event.ctrlKey) {
// const screenPosition = Vector2.fromEvent(event);
// const newPan = screenPosition
// .add(pan)
// .scale(newZoom / zoom)
// .sub(screenPosition);
this.camera = {
...this.camera,
zoom: Math.exp(-event.deltaY / 100) * this.camera.zoom,
};
} else {
this.camera = {
...this.camera,
center: new Vector2(event.deltaX, event.deltaY)
.scale(1 / this.camera.zoom)
.add(this.camera.center),
};
}
};

@reactive accessor pointers: ReadonlyMap<GLPointerId, GLPointer> =
new Map();
getPointer(id: GLPointerId) {
return this.pointers.get(id);
}
setPointer(pointer: GLPointer) {
const map = new Map(this.pointers);
map.set(pointer.pointerId, pointer);
this.pointers = map;
}
setPointerState(pointer: GLPointer, state: PointerState) {
this.setPointer({
...(this.getPointer(pointer.pointerId) ?? pointer),
state,
});
}

private updatePointerAndReturnEvent(
event: PointerEvent,
phase: "down" | "move" | "up" | "cancel",
): GLPointerEvent & GLPointer {
const existingPointer = this.getPointer(event.pointerId);
const pointer: GLPointer = {
pointerId: event.pointerId,
viewportPosition: Vector2.fromEvent(event),
type: event.pointerType as GLPointer["type"],
isDown:
phase === "down" ? true
: phase === "up" || phase === "cancel" ? false
: existingPointer?.isDown ?? false,
state: existingPointer?.state ?? { type: "idle" },
lastUpdatedAt: Date.now(),
};
this.setPointer(pointer);
return {
...pointer,
pagePosition: this.viewportToPage.apply(pointer.viewportPosition),
};
}
onPointerDown = (rawEvent: PointerEvent) => {
rawEvent.preventDefault();
this.container.setPointerCapture(rawEvent.pointerId);
const event = this.updatePointerAndReturnEvent(rawEvent, "down");
const enabledGestures = this.enabledGestures;

Check warning on line 205 in src/gestureland/Gestureland.tsx

View workflow job for this annotation

GitHub Actions / build

'enabledGestures' is assigned a value but never used. Allowed unused vars must match /^_/u

const state = event.state;
switch (state.type) {
// 1: check if this could be a part of an existing gesture:
case "drag": {
// already dragging, how do pointer down???? do nothing
return;
}
// 2: try and begin a new gesture
case "idle": {
// const target =
// const drag = target.onDrag(event);
// this.setPointerState(event, {
// type: "drag",
// isConfirmed: false,
// target,
// drag,
// });

return;
}

default:
exhaustiveSwitchError(state);
}
};
onPointerMove = (rawEvent: PointerEvent) => {
const event = this.updatePointerAndReturnEvent(rawEvent, "move");
const state = event.state;
switch (state.type) {
case "idle": {
return;
}
case "drag": {
state.drag.onUpdate(event);
return;
}
default:
exhaustiveSwitchError(state);
}
};
onPointerUp = (rawEvent: PointerEvent) => {
this.container.releasePointerCapture(rawEvent.pointerId);
const event = this.updatePointerAndReturnEvent(rawEvent, "up");
const state = event.state;
switch (state.type) {
case "idle": {
return;
}
case "drag": {
state.drag.onEnd?.(event);
this.setPointerState(event, { type: "idle" });
return;
}
default:
exhaustiveSwitchError(state);
}
};
onPointerCancel = (rawEvent: PointerEvent) => {
this.container.releasePointerCapture(rawEvent.pointerId);
const event = this.updatePointerAndReturnEvent(rawEvent, "cancel");
const state = event.state;
switch (state.type) {
case "idle":
return;
case "drag":
state.drag.onCancel?.(event);
this.setPointerState(event, { type: "idle" });
return;
default:
exhaustiveSwitchError(state);
}
};
onTick = () => {
const expireOlderThan = Date.now() - EXPIRE_POINTER_AFTER_MS;
const nextPointers = new Map(this.pointers);
for (const pointer of this.pointers.values()) {
if (pointer.lastUpdatedAt < expireOlderThan) {
nextPointers.delete(pointer.pointerId);
}
}
this.pointers = nextPointers;
};
// #endregion
}
Loading

0 comments on commit 21229c8

Please sign in to comment.