atagen

malice

public
Star0
Watch0
b9b85b76355e2e8d0874d95d161058b25e5d3111
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/longpress.tsTypescript162 lines4.4 KB
1
import { readable, type Readable } from 'svelte/store';
2
3
export const LONG_PRESS_MS = 450;
4
export const LONG_PRESS_SLOP = 8;
5
6
export interface PressEvent {
7
  clientX: number;
8
  clientY: number;
9
  currentTarget: EventTarget | null;
10
}
11
12
export interface Timers {
13
  set(fn: () => void, ms: number): unknown;
14
  clear(handle: unknown): void;
15
}
16
17
const realTimers: Timers = {
18
  set: (fn, ms) => setTimeout(fn, ms),
19
  clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>)
20
};
21
22
export class PressTracker {
23
  fired = false;
24
  private handle: unknown = null;
25
  private x = 0;
26
  private y = 0;
27
28
  constructor(
29
    private readonly onFire: (x: number, y: number) => void,
30
    private readonly timers: Timers = realTimers,
31
    private readonly delay = LONG_PRESS_MS,
32
    private readonly slop = LONG_PRESS_SLOP
33
  ) {}
34
35
  get pending(): boolean {
36
    return this.handle !== null;
37
  }
38
39
  start(x: number, y: number): void {
40
    this.cancel();
41
    this.fired = false;
42
    this.x = x;
43
    this.y = y;
44
    this.handle = this.timers.set(() => {
45
      this.handle = null;
46
      this.fired = true;
47
      this.onFire(this.x, this.y);
48
    }, this.delay);
49
  }
50
51
  move(x: number, y: number): void {
52
    if (this.handle === null) return;
53
    if (Math.hypot(x - this.x, y - this.y) > this.slop) this.cancel();
54
  }
55
56
  flush(): void {
57
    if (this.handle === null) return;
58
    this.cancel();
59
    this.fired = true;
60
    this.onFire(this.x, this.y);
61
  }
62
63
  cancel(): void {
64
    if (this.handle === null) return;
65
    this.timers.clear(this.handle);
66
    this.handle = null;
67
  }
68
}
69
70
type Handler = ((ev: PressEvent) => void) | null | undefined;
71
72
interface Options {
73
  on: Handler;
74
  allowSelection?: boolean;
75
  ignore?: string;
76
}
77
78
type Param = Handler | Options;
79
80
function split(param: Param): Options {
81
  return typeof param === 'function' || !param ? { on: param } : param;
82
}
83
84
export function longpress(node: HTMLElement, param: Param) {
85
  let opts = split(param);
86
  let current = opts.on;
87
  let pointerId = -1;
88
89
  const tracker = new PressTracker((x, y) => {
90
    navigator.vibrate?.(10);
91
    current?.({ clientX: x, clientY: y, currentTarget: node });
92
  });
93
94
  const apply = () => {
95
    node.style.setProperty('-webkit-touch-callout', 'none');
96
    if (!opts.allowSelection) node.style.setProperty('user-select', 'none');
97
  };
98
  apply();
99
100
  function down(e: PointerEvent) {
101
    if (!current || e.pointerType === 'mouse' || !e.isPrimary) return;
102
    if (opts.ignore && (e.target as Element).closest(opts.ignore)) return;
103
    pointerId = e.pointerId;
104
    tracker.start(e.clientX, e.clientY);
105
  }
106
  function move(e: PointerEvent) {
107
    if (e.pointerId === pointerId) tracker.move(e.clientX, e.clientY);
108
  }
109
  function end(e: PointerEvent) {
110
    if (e.pointerId !== pointerId) return;
111
    tracker.cancel();
112
    if (tracker.fired) setTimeout(() => (tracker.fired = false), 400);
113
  }
114
  function click(e: MouseEvent) {
115
    if (!tracker.fired) return;
116
    tracker.fired = false;
117
    e.preventDefault();
118
    e.stopImmediatePropagation();
119
  }
120
  function contextmenu(e: Event) {
121
    if (!tracker.fired && !tracker.pending) return;
122
    e.preventDefault();
123
    e.stopImmediatePropagation();
124
    tracker.flush();
125
  }
126
  const cancel = () => tracker.cancel();
127
128
  node.addEventListener('pointerdown', down);
129
  node.addEventListener('pointermove', move);
130
  node.addEventListener('pointerup', end);
131
  node.addEventListener('pointercancel', end);
132
  node.addEventListener('click', click, true);
133
  node.addEventListener('contextmenu', contextmenu, true);
134
  window.addEventListener('scroll', cancel, true);
135
136
  return {
137
    update(next: Param) {
138
      opts = split(next);
139
      current = opts.on;
140
      apply();
141
    },
142
    destroy() {
143
      tracker.cancel();
144
      node.removeEventListener('pointerdown', down);
145
      node.removeEventListener('pointermove', move);
146
      node.removeEventListener('pointerup', end);
147
      node.removeEventListener('pointercancel', end);
148
      node.removeEventListener('click', click, true);
149
      node.removeEventListener('contextmenu', contextmenu, true);
150
      window.removeEventListener('scroll', cancel, true);
151
    }
152
  };
153
}
154
155
export const finePointer: Readable<boolean> = readable(true, (set) => {
156
  if (typeof window === 'undefined') return;
157
  const query = window.matchMedia('(pointer: fine)');
158
  set(query.matches);
159
  const update = () => set(query.matches);
160
  query.addEventListener('change', update);
161
  return () => query.removeEventListener('change', update);
162
});