@hypen-space/web 0.2.12 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/src/dom/applicators/effects.js +38 -2
  2. package/dist/src/dom/applicators/effects.js.map +3 -3
  3. package/dist/src/dom/applicators/events.js +280 -397
  4. package/dist/src/dom/applicators/events.js.map +5 -4
  5. package/dist/src/dom/applicators/font.js +94 -5
  6. package/dist/src/dom/applicators/font.js.map +3 -3
  7. package/dist/src/dom/applicators/index.js +590 -425
  8. package/dist/src/dom/applicators/index.js.map +10 -9
  9. package/dist/src/dom/applicators/layout.js +33 -5
  10. package/dist/src/dom/applicators/layout.js.map +3 -3
  11. package/dist/src/dom/applicators/size.js +81 -16
  12. package/dist/src/dom/applicators/size.js.map +3 -3
  13. package/dist/src/dom/components/hypenapp.js +296 -0
  14. package/dist/src/dom/components/hypenapp.js.map +10 -0
  15. package/dist/src/dom/components/index.js +263 -1
  16. package/dist/src/dom/components/index.js.map +5 -4
  17. package/dist/src/dom/element-data.js +140 -0
  18. package/dist/src/dom/element-data.js.map +10 -0
  19. package/dist/src/dom/index.js +857 -430
  20. package/dist/src/dom/index.js.map +13 -11
  21. package/dist/src/dom/renderer.js +857 -430
  22. package/dist/src/dom/renderer.js.map +13 -11
  23. package/dist/src/hypen.js +857 -430
  24. package/dist/src/hypen.js.map +13 -11
  25. package/dist/src/index.js +862 -430
  26. package/dist/src/index.js.map +15 -12
  27. package/package.json +3 -3
  28. package/src/canvas/QUICKSTART.md +2 -4
  29. package/src/dom/applicators/effects.ts +45 -1
  30. package/src/dom/applicators/events.ts +348 -537
  31. package/src/dom/applicators/font.ts +127 -2
  32. package/src/dom/applicators/index.ts +117 -7
  33. package/src/dom/applicators/layout.ts +40 -4
  34. package/src/dom/applicators/size.ts +101 -16
  35. package/src/dom/components/hypenapp.ts +348 -0
  36. package/src/dom/components/index.ts +2 -0
  37. package/src/dom/element-data.ts +234 -0
  38. package/src/dom/renderer.ts +8 -5
  39. package/src/index.ts +3 -0
@@ -1,14 +1,55 @@
1
1
  /**
2
2
  * Event Applicators
3
- *
3
+ *
4
4
  * Handles event applicators like onClick, onPress, etc.
5
+ * Uses a factory pattern to reduce boilerplate and ensure consistency.
5
6
  */
6
7
 
7
8
  import type { ApplicatorHandler } from "./index.js";
9
+ import {
10
+ getElementDisposables,
11
+ disposableListener,
12
+ disposableTimeout,
13
+ type Disposable,
14
+ } from "@hypen/core";
15
+ import {
16
+ type IEngine,
17
+ getEngine,
18
+ getRegisteredEvents,
19
+ registerEvent,
20
+ unregisterEvent,
21
+ getKeyTarget,
22
+ setKeyTarget,
23
+ } from "../element-data.js";
24
+
25
+ // ============================================================================
26
+ // Types
27
+ // ============================================================================
28
+
29
+ interface EventHandlerOptions {
30
+ /** Custom payload extractor for this event type */
31
+ extractPayload?: (event: Event, element: HTMLElement) => Record<string, unknown>;
32
+ /** Throttle events to max one per N milliseconds */
33
+ throttleMs?: number;
34
+ /** Prevent default behavior */
35
+ preventDefault?: boolean;
36
+ /** Use passive listener (for scroll, touch) */
37
+ passive?: boolean;
38
+ /** Key to listen for (keyboard events) */
39
+ key?: string;
40
+ }
41
+
42
+
43
+ // ============================================================================
44
+ // Utility Functions
45
+ // ============================================================================
8
46
 
9
- function toPlainObject(value: any): any {
47
+ /**
48
+ * Convert Map or nested objects to plain objects
49
+ */
50
+ function toPlainObject(value: unknown): unknown {
10
51
  if (value instanceof Map) {
11
- const obj: Record<string, any> = {};
52
+ const obj: Record<string, unknown> = {};
12
53
  for (const [key, val] of value.entries()) {
13
54
  obj[key] = toPlainObject(val);
14
55
  }
@@ -20,7 +61,7 @@ function toPlainObject(value: any): any {
20
61
  }
21
62
 
22
63
  if (value && typeof value === "object") {
23
- const obj: Record<string, any> = {};
64
+ const obj: Record<string, unknown> = {};
24
65
  for (const [key, val] of Object.entries(value)) {
25
66
  obj[key] = toPlainObject(val);
26
67
  }
@@ -30,7 +71,14 @@ function toPlainObject(value: any): any {
30
71
  return value;
31
72
  }
32
73
 
33
- function extractActionDetails(value: any): { actionName: string | null; payload: Record<string, any> } {
74
+ /**
75
+ * Extract action name and custom payload from an applicator value
76
+ */
77
+ function extractActionDetails(value: unknown): {
78
+ actionName: string | null;
79
+ payload: Record<string, unknown>;
80
+ } {
81
+ // String format: "@actions.doSomething" or "@doSomething"
34
82
  if (typeof value === "string") {
35
83
  if (!value.startsWith("@")) {
36
84
  return { actionName: null, payload: {} };
@@ -43,9 +91,10 @@ function extractActionDetails(value: any): { actionName: string | null; payload:
43
91
  return { actionName, payload: {} };
44
92
  }
45
93
 
94
+ // Object format: { "0": "@actions.doSomething", "customKey": "value" }
46
95
  if (value && typeof value === "object") {
47
- const plain = toPlainObject(value);
48
- const payload: Record<string, any> = {};
96
+ const plain = toPlainObject(value) as Record<string, unknown>;
97
+ const payload: Record<string, unknown> = {};
49
98
  let actionName: string | null = null;
50
99
 
51
100
  if (plain && typeof plain === "object") {
@@ -59,7 +108,17 @@ function extractActionDetails(value: any): { actionName: string | null; payload:
59
108
 
60
109
  for (const [key, val] of Object.entries(plain)) {
61
110
  if (key !== "0") {
62
- payload[key] = val;
111
+ // If the key is numeric (like "1", "2") and the value is an object,
112
+ // merge the object's keys into the payload directly.
113
+ // This handles: .onClick("@actions.foo", { id: "123" })
114
+ // where the second positional arg becomes "1": { id: "123" }
115
+ if (/^\d+$/.test(key) && val && typeof val === "object" && !Array.isArray(val)) {
116
+ for (const [innerKey, innerVal] of Object.entries(val)) {
117
+ payload[innerKey] = innerVal;
118
+ }
119
+ } else {
120
+ payload[key] = val;
121
+ }
63
122
  }
64
123
  }
65
124
  }
@@ -70,620 +129,372 @@ function extractActionDetails(value: any): { actionName: string | null; payload:
70
129
  return { actionName: null, payload: {} };
71
130
  }
72
131
 
73
- export const eventHandlers: Record<string, ApplicatorHandler> = {
74
- onClick: (element, value) => {
75
- console.log(`[EventApplicator] onClick called with value:`, value);
132
+ /**
133
+ * Extract relevant data from a DOM event
134
+ */
135
+ function extractEventData(event: Event, element: HTMLElement): Record<string, unknown> {
136
+ const data: Record<string, unknown> = {
137
+ type: event.type,
138
+ timestamp: Date.now(),
139
+ };
76
140
 
77
- const { actionName, payload: customPayload } = extractActionDetails(value);
141
+ // Mouse events
142
+ if (event instanceof MouseEvent) {
143
+ data.clientX = event.clientX;
144
+ data.clientY = event.clientY;
145
+ data.button = event.button;
146
+ }
78
147
 
79
- if (!actionName) {
80
- console.warn(`[EventApplicator] onClick value must be an action reference, got:`, value);
81
- return;
82
- }
148
+ // Keyboard events
149
+ if (event instanceof KeyboardEvent) {
150
+ data.key = event.key;
151
+ data.code = event.code;
152
+ data.ctrlKey = event.ctrlKey;
153
+ data.shiftKey = event.shiftKey;
154
+ data.altKey = event.altKey;
155
+ data.metaKey = event.metaKey;
156
+ }
83
157
 
84
- // Remove existing click listener if any
85
- const existingListener = (element as any).__hypenClickListener;
86
- if (existingListener) {
87
- element.removeEventListener("click", existingListener);
88
- }
158
+ // Input element values
159
+ if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
160
+ data.value = element.value;
161
+ }
89
162
 
90
- // Create new click listener
91
- const listener = (event: Event) => {
92
- console.log(`🔥 [EventApplicator] onClick fired, dispatching action: ${actionName}`);
163
+ // Select element values
164
+ if (element instanceof HTMLSelectElement) {
165
+ data.value = element.value;
166
+ data.selectedIndex = element.selectedIndex;
167
+ }
93
168
 
94
- // Use custom payload if provided, otherwise extract event data
95
- const payload = Object.keys(customPayload).length > 0
96
- ? { ...customPayload }
97
- : extractEventData(event, element);
169
+ // Form data
170
+ if (event.type === "submit" && element instanceof HTMLFormElement) {
171
+ data.formData = new FormData(element);
172
+ }
98
173
 
99
- console.log(`[EventApplicator] onClick payload:`, payload);
174
+ return data;
175
+ }
100
176
 
101
- // Dispatch action to engine
102
- const engine = (element as any).__hypenEngine;
103
- if (engine) {
104
- engine.dispatchAction(actionName, payload);
105
- } else {
106
- console.warn(`[EventApplicator] No engine attached to element for onClick`);
107
- }
108
- };
109
177
 
110
- // Store listener reference and attach to DOM
111
- (element as any).__hypenClickListener = listener;
112
- element.addEventListener("click", listener);
178
+ /**
179
+ * Capitalize first letter of a string
180
+ */
181
+ function capitalize(str: string): string {
182
+ return str.charAt(0).toUpperCase() + str.slice(1);
183
+ }
113
184
 
114
- console.log(`[EventApplicator] onClick handler attached for action: ${actionName}`);
115
- },
185
+ // ============================================================================
186
+ // Event Handler Factory
187
+ // ============================================================================
116
188
 
117
- onPress: (element, value) => {
118
- // For now, treat onPress same as onClick
119
- // In the future, this could handle touch events differently
120
- eventHandlers.onClick(element, value);
121
- },
122
189
 
123
- onChange: (element, value) => {
124
- const { actionName } = extractActionDetails(value);
190
+ /**
191
+ * Create an event handler applicator with common boilerplate
192
+ */
193
+ function createEventHandler(
194
+ eventType: string,
195
+ options: EventHandlerOptions = {}
196
+ ): ApplicatorHandler {
197
+ return (element: HTMLElement, value: unknown) => {
198
+ const { actionName, payload: customPayload } = extractActionDetails(value);
125
199
 
126
200
  if (!actionName) {
127
- console.warn(`[EventApplicator] onChange value must be an action reference starting with @, got:`, value);
201
+ console.warn(
202
+ `[EventApplicator] ${eventType} requires an action reference starting with @, got:`,
203
+ value
204
+ );
128
205
  return;
129
206
  }
130
207
 
131
- const existingListener = (element as any).__hypenChangeListener;
132
- if (existingListener) {
133
- element.removeEventListener("change", existingListener);
134
- }
135
-
136
- const listener = (event: Event) => {
137
- console.log(`🔥 [EventApplicator] onChange fired, dispatching action: ${actionName}`);
138
-
139
- const payload = extractEventData(event, element);
140
-
141
- const engine = (element as any).__hypenEngine;
142
- if (engine) {
143
- engine.dispatchAction(actionName, payload);
144
- } else {
145
- console.warn(`[EventApplicator] No engine attached to element for onChange`);
146
- }
147
- };
148
-
149
- (element as any).__hypenChangeListener = listener;
150
- element.addEventListener("change", listener);
208
+ const disposables = getElementDisposables(element);
151
209
 
152
- console.log(`[EventApplicator] onChange handler attached for action: ${actionName}`);
153
- },
154
-
155
- onSubmit: (element, value) => {
156
- const { actionName } = extractActionDetails(value);
157
-
158
- if (!actionName) {
159
- console.warn(`[EventApplicator] onSubmit value must be an action reference starting with @, got:`, value);
210
+ // Track that we've registered this event type
211
+ // The disposable stack handles cleanup automatically
212
+ const eventKey = `${eventType}:${actionName}`;
213
+ if (getRegisteredEvents(element).has(eventKey)) {
214
+ // Already registered - skip to avoid duplicates
215
+ // This can happen during re-renders
160
216
  return;
161
217
  }
218
+ registerEvent(element, eventKey);
162
219
 
163
- const existingListener = (element as any).__hypenSubmitListener;
164
- if (existingListener) {
165
- element.removeEventListener("submit", existingListener);
166
- }
220
+ // Create throttle state if needed
221
+ let throttleTimer: Disposable | null = null;
167
222
 
223
+ // Create the event listener
168
224
  const listener = (event: Event) => {
169
- console.log(`🔥 [EventApplicator] onSubmit fired, dispatching action: ${actionName}`);
170
-
171
- event.preventDefault();
172
-
173
- const payload = extractEventData(event, element);
174
-
175
- const engine = (element as any).__hypenEngine;
176
- if (engine) {
177
- engine.dispatchAction(actionName, payload);
178
- } else {
179
- console.warn(`[EventApplicator] No engine attached to element for onSubmit`);
225
+ // Handle throttling
226
+ if (options.throttleMs && throttleTimer) {
227
+ return;
180
228
  }
181
- };
182
-
183
- (element as any).__hypenSubmitListener = listener;
184
- element.addEventListener("submit", listener);
185
229
 
186
- console.log(`[EventApplicator] onSubmit handler attached for action: ${actionName}`);
187
- },
188
-
189
- onInput: (element, value) => {
190
- console.log(`[EventApplicator] onInput called with value:`, value);
191
-
192
- const { actionName } = extractActionDetails(value);
193
-
194
- if (!actionName) {
195
- console.warn(`[EventApplicator] onInput value must be an action reference starting with @, got:`, value);
196
- return;
197
- }
198
-
199
- const existingListener = (element as any).__hypenInputListener;
200
- if (existingListener) {
201
- element.removeEventListener("input", existingListener);
202
- }
203
-
204
- const listener = (event: Event) => {
205
- console.log(`🔥 [EventApplicator] onInput fired, dispatching action: ${actionName}`);
230
+ if (options.throttleMs) {
231
+ throttleTimer = disposableTimeout(() => {
232
+ throttleTimer = null;
233
+ }, options.throttleMs);
234
+ }
206
235
 
207
- const target = event.target as HTMLInputElement | HTMLTextAreaElement;
208
- const payload = {
209
- type: event.type,
210
- timestamp: Date.now(),
211
- value: target.value,
212
- input: target.value,
213
- };
236
+ // Handle preventDefault
237
+ if (options.preventDefault) {
238
+ event.preventDefault();
239
+ }
214
240
 
215
- console.log(`[EventApplicator] onInput payload:`, payload);
241
+ // Build payload
242
+ const payload =
243
+ Object.keys(customPayload).length > 0
244
+ ? { ...customPayload }
245
+ : options.extractPayload
246
+ ? options.extractPayload(event, element)
247
+ : extractEventData(event, element);
216
248
 
217
- const engine = (element as any).__hypenEngine;
249
+ // Dispatch to engine
250
+ const engine = getEngine(element);
218
251
  if (engine) {
219
252
  engine.dispatchAction(actionName, payload);
220
- } else {
221
- console.warn(`[EventApplicator] No engine attached to element for onInput`);
222
253
  }
223
254
  };
224
255
 
225
- (element as any).__hypenInputListener = listener;
226
- element.addEventListener("input", listener);
227
-
228
- console.log(`[EventApplicator] onInput handler attached for action: ${actionName}`);
229
- },
230
-
231
- onKey: (element, value) => {
232
- console.log(`[EventApplicator] onKey called with value:`, value);
233
-
234
- // onKey can receive either:
235
- // 1. Just an action: "@action.submit"
236
- // 2. Key-specific format from props like onKey.key = "return", onKey.action = "@action.submit"
237
-
238
- // For now, we'll handle the simple case where onKey receives the action directly
239
- // and triggers on Enter key by default
240
- const { actionName } = extractActionDetails(value);
241
-
242
- if (actionName) {
243
-
244
- const existingListener = (element as any).__hypenKeyListener;
245
- if (existingListener) {
246
- element.removeEventListener("keydown", existingListener);
256
+ // Register the listener using disposable pattern
257
+ disposables.add(
258
+ disposableListener(element, eventType, listener, {
259
+ passive: options.passive,
260
+ })
261
+ );
262
+
263
+ // Clean up registered events tracking on dispose
264
+ disposables.addCallback(() => {
265
+ unregisterEvent(element, eventKey);
266
+ if (throttleTimer) {
267
+ throttleTimer.dispose();
247
268
  }
248
-
249
- const listener = (event: KeyboardEvent) => {
250
- // Default to Enter key
251
- if (event.key === "Enter") {
252
- console.log(`🔥 [EventApplicator] onKey fired (Enter), dispatching action: ${actionName}`);
253
-
254
- event.preventDefault();
255
-
256
- const target = event.target as HTMLInputElement | HTMLTextAreaElement;
257
- const payload = {
258
- type: event.type,
259
- timestamp: Date.now(),
260
- key: event.key,
261
- code: event.code,
262
- value: target.value,
263
- input: target.value,
264
- ctrlKey: event.ctrlKey,
265
- shiftKey: event.shiftKey,
266
- altKey: event.altKey,
267
- metaKey: event.metaKey,
268
- };
269
-
270
- const engine = (element as any).__hypenEngine;
271
- if (engine) {
272
- engine.dispatchAction(actionName, payload);
273
- } else {
274
- console.warn(`[EventApplicator] No engine attached to element for onKey`);
275
- }
276
- }
277
- };
278
-
279
- (element as any).__hypenKeyListener = listener;
280
- element.addEventListener("keydown", listener);
281
-
282
- console.log(`[EventApplicator] onKey handler attached for action: ${actionName} (triggers on Enter)`);
283
- } else {
284
- console.warn(`[EventApplicator] onKey value must be an action reference starting with @, got: ${value}`);
285
- }
286
- },
287
-
288
- // Handle key-specific applicator: onKey.key for specifying which key
289
- "onKey.key": (element, keyValue) => {
290
- console.log(`[EventApplicator] onKey.key called with value:`, keyValue);
291
- // Store the key to check in the listener
292
- (element as any).__hypenKeyTarget = keyValue;
293
- },
294
-
295
- // Handle action for specific key: onKey.action
296
- "onKey.action": (element, value) => {
297
- console.log(`[EventApplicator] onKey.action called with value:`, value);
298
-
299
- const { actionName } = extractActionDetails(value);
300
-
301
- if (actionName) {
302
-
303
- const targetKey = (element as any).__hypenKeyTarget || "Enter";
304
-
305
- const existingListener = (element as any).__hypenKeyListener;
306
- if (existingListener) {
307
- element.removeEventListener("keydown", existingListener);
308
- }
309
-
310
- const listener = (event: KeyboardEvent) => {
311
- const keyToMatch = targetKey.toLowerCase() === "return" ? "Enter" : targetKey;
312
-
313
- if (event.key === keyToMatch) {
314
- console.log(`🔥 [EventApplicator] onKey fired (${keyToMatch}), dispatching action: ${actionName}`);
315
-
316
- event.preventDefault();
317
-
318
- const target = event.target as HTMLInputElement | HTMLTextAreaElement;
319
- const payload = {
320
- type: event.type,
321
- timestamp: Date.now(),
322
- key: event.key,
323
- code: event.code,
324
- value: target.value,
325
- input: target.value,
326
- ctrlKey: event.ctrlKey,
327
- shiftKey: event.shiftKey,
328
- altKey: event.altKey,
329
- metaKey: event.metaKey,
330
- };
331
-
332
- const engine = (element as any).__hypenEngine;
333
- if (engine) {
334
- engine.dispatchAction(actionName, payload);
335
- }
336
- }
337
- };
338
-
339
- (element as any).__hypenKeyListener = listener;
340
- element.addEventListener("keydown", listener);
341
-
342
- console.log(`[EventApplicator] onKey handler attached for action: ${actionName} on key: ${targetKey}`);
343
- }
344
- },
345
-
346
- onScroll: (element, value) => {
347
- console.log(`[EventApplicator] onScroll called with value:`, value);
348
-
349
- const { actionName } = extractActionDetails(value);
350
-
351
- if (actionName) {
352
-
353
- // Remove existing scroll listener if any
354
- const existingListener = (element as any).__hypenScrollListener;
355
- if (existingListener) {
356
- element.removeEventListener("scroll", existingListener);
357
- }
358
-
359
- // Create new scroll listener with throttling to avoid too many events
360
- let throttleTimer: number | null = null;
361
- const listener = (event: Event) => {
362
- if (throttleTimer) return;
363
-
364
- throttleTimer = setTimeout(() => {
365
- throttleTimer = null;
366
- }, 100) as unknown as number; // Throttle to max 10 events/second
367
-
368
- const target = event.target as HTMLElement;
369
- const scrollTop = target.scrollTop;
370
- const scrollHeight = target.scrollHeight;
371
- const clientHeight = target.clientHeight;
372
- const scrollPercentage = (scrollTop / (scrollHeight - clientHeight)) * 100;
373
-
374
- // Calculate if near bottom (within 100px or 90% scrolled)
375
- const nearBottom = scrollHeight - scrollTop - clientHeight < 100 || scrollPercentage > 90;
376
-
377
- console.log(`🔥 [EventApplicator] onScroll fired, scrollTop: ${scrollTop}, nearBottom: ${nearBottom}`);
378
-
379
- const payload = {
380
- type: "scroll",
381
- timestamp: Date.now(),
382
- scrollTop,
383
- scrollLeft: target.scrollLeft,
384
- scrollHeight,
385
- scrollWidth: target.scrollWidth,
386
- clientHeight,
387
- clientWidth: target.clientWidth,
388
- scrollPercentage: Math.round(scrollPercentage),
389
- nearBottom,
390
- atBottom: scrollHeight - scrollTop === clientHeight,
391
- atTop: scrollTop === 0,
392
- };
393
-
394
- const engine = (element as any).__hypenEngine;
395
- if (engine) {
396
- engine.dispatchAction(actionName, payload);
397
- } else {
398
- console.warn(`[EventApplicator] No engine attached to element for onScroll`);
399
- }
400
- };
401
-
402
- (element as any).__hypenScrollListener = listener;
403
- element.addEventListener("scroll", listener, { passive: true });
404
-
405
- console.log(`[EventApplicator] onScroll handler attached for action: ${actionName}`);
406
- } else {
407
- console.warn(`[EventApplicator] onScroll value must be an action reference starting with @, got: ${value}`);
408
- }
409
- },
410
-
411
- onLongClick: (element, value) => {
412
- console.log(`[EventApplicator] onLongClick called with value:`, value);
269
+ });
270
+ };
271
+ }
413
272
 
273
+ /**
274
+ * Create a keyboard event handler that filters by key
275
+ */
276
+ function createKeyHandler(defaultKey: string = "Enter"): ApplicatorHandler {
277
+ return (element: HTMLElement, value: unknown) => {
414
278
  const { actionName, payload: customPayload } = extractActionDetails(value);
415
279
 
416
280
  if (!actionName) {
417
- console.warn(`[EventApplicator] onLongClick value must be an action reference, got:`, value);
281
+ console.warn(
282
+ `[EventApplicator] onKey requires an action reference starting with @, got:`,
283
+ value
284
+ );
418
285
  return;
419
286
  }
420
287
 
421
- // Remove existing long click listeners if any
422
- const existingDownListener = (element as any).__hypenLongClickDownListener;
423
- const existingUpListener = (element as any).__hypenLongClickUpListener;
424
- if (existingDownListener) {
425
- element.removeEventListener("pointerdown", existingDownListener);
426
- }
427
- if (existingUpListener) {
428
- element.removeEventListener("pointerup", existingUpListener);
429
- element.removeEventListener("pointerleave", existingUpListener);
288
+ const disposables = getElementDisposables(element);
289
+
290
+ const eventKey = `keydown:${actionName}:${defaultKey}`;
291
+ if (getRegisteredEvents(element).has(eventKey)) {
292
+ return;
430
293
  }
294
+ registerEvent(element, eventKey);
431
295
 
432
- // Long click implementation using pointer events with 500ms threshold
433
- let longClickTimer: ReturnType<typeof setTimeout> | null = null;
434
- const LONG_CLICK_THRESHOLD = 500; // 500ms
296
+ // Get target key from element data or use default
297
+ const targetKey = getKeyTarget(element) || defaultKey;
298
+ const keyToMatch = targetKey.toLowerCase() === "return" ? "Enter" : targetKey;
435
299
 
436
- const downListener = (event: PointerEvent) => {
437
- longClickTimer = setTimeout(() => {
438
- console.log(`🔥 [EventApplicator] onLongClick fired, dispatching action: ${actionName}`);
300
+ const listener = (event: Event) => {
301
+ const keyEvent = event as KeyboardEvent;
302
+ if (keyEvent.key !== keyToMatch) {
303
+ return;
304
+ }
305
+
306
+ event.preventDefault();
439
307
 
440
- const payload = Object.keys(customPayload).length > 0
308
+ const target = event.target as HTMLInputElement | HTMLTextAreaElement;
309
+ const payload =
310
+ Object.keys(customPayload).length > 0
441
311
  ? { ...customPayload }
442
312
  : {
443
- type: "longclick",
313
+ type: event.type,
444
314
  timestamp: Date.now(),
445
- clientX: event.clientX,
446
- clientY: event.clientY,
315
+ key: keyEvent.key,
316
+ code: keyEvent.code,
317
+ value: target.value,
318
+ input: target.value,
319
+ ctrlKey: keyEvent.ctrlKey,
320
+ shiftKey: keyEvent.shiftKey,
321
+ altKey: keyEvent.altKey,
322
+ metaKey: keyEvent.metaKey,
447
323
  };
448
324
 
449
- const engine = (element as any).__hypenEngine;
450
- if (engine) {
451
- engine.dispatchAction(actionName, payload);
452
- } else {
453
- console.warn(`[EventApplicator] No engine attached to element for onLongClick`);
454
- }
455
-
456
- longClickTimer = null;
457
- }, LONG_CLICK_THRESHOLD);
458
- };
459
-
460
- const upListener = () => {
461
- if (longClickTimer) {
462
- clearTimeout(longClickTimer);
463
- longClickTimer = null;
464
- }
465
- };
466
-
467
- (element as any).__hypenLongClickDownListener = downListener;
468
- (element as any).__hypenLongClickUpListener = upListener;
469
- element.addEventListener("pointerdown", downListener);
470
- element.addEventListener("pointerup", upListener);
471
- element.addEventListener("pointerleave", upListener);
472
-
473
- console.log(`[EventApplicator] onLongClick handler attached for action: ${actionName}`);
474
- },
475
-
476
- onFocus: (element, value) => {
477
- console.log(`[EventApplicator] onFocus called with value:`, value);
478
-
479
- const { actionName, payload: customPayload } = extractActionDetails(value);
480
-
481
- if (!actionName) {
482
- console.warn(`[EventApplicator] onFocus value must be an action reference, got:`, value);
483
- return;
484
- }
485
-
486
- // Remove existing focus listener if any
487
- const existingListener = (element as any).__hypenFocusListener;
488
- if (existingListener) {
489
- element.removeEventListener("focus", existingListener);
490
- }
491
-
492
- const listener = (event: FocusEvent) => {
493
- console.log(`🔥 [EventApplicator] onFocus fired, dispatching action: ${actionName}`);
494
-
495
- const target = event.target as HTMLElement;
496
- const payload = Object.keys(customPayload).length > 0
497
- ? { ...customPayload }
498
- : {
499
- type: "focus",
500
- timestamp: Date.now(),
501
- value: (target as HTMLInputElement).value ?? undefined,
502
- };
503
-
504
- const engine = (element as any).__hypenEngine;
325
+ const engine = getEngine(element);
505
326
  if (engine) {
506
327
  engine.dispatchAction(actionName, payload);
507
- } else {
508
- console.warn(`[EventApplicator] No engine attached to element for onFocus`);
509
328
  }
510
329
  };
511
330
 
512
- (element as any).__hypenFocusListener = listener;
513
- element.addEventListener("focus", listener);
514
-
515
- console.log(`[EventApplicator] onFocus handler attached for action: ${actionName}`);
516
- },
517
-
518
- onBlur: (element, value) => {
519
- console.log(`[EventApplicator] onBlur called with value:`, value);
331
+ disposables.add(disposableListener(element, "keydown", listener));
332
+ disposables.addCallback(() => {
333
+ unregisterEvent(element, eventKey);
334
+ });
335
+ };
336
+ }
520
337
 
338
+ /**
339
+ * Create a long-click/long-press handler
340
+ */
341
+ function createLongClickHandler(thresholdMs: number = 500): ApplicatorHandler {
342
+ return (element: HTMLElement, value: unknown) => {
521
343
  const { actionName, payload: customPayload } = extractActionDetails(value);
522
344
 
523
345
  if (!actionName) {
524
- console.warn(`[EventApplicator] onBlur value must be an action reference, got:`, value);
346
+ console.warn(
347
+ `[EventApplicator] onLongClick requires an action reference starting with @, got:`,
348
+ value
349
+ );
525
350
  return;
526
351
  }
527
352
 
528
- // Remove existing blur listener if any
529
- const existingListener = (element as any).__hypenBlurListener;
530
- if (existingListener) {
531
- element.removeEventListener("blur", existingListener);
532
- }
533
-
534
- const listener = (event: FocusEvent) => {
535
- console.log(`🔥 [EventApplicator] onBlur fired, dispatching action: ${actionName}`);
536
-
537
- const target = event.target as HTMLElement;
538
- const payload = Object.keys(customPayload).length > 0
539
- ? { ...customPayload }
540
- : {
541
- type: "blur",
542
- timestamp: Date.now(),
543
- value: (target as HTMLInputElement).value ?? undefined,
544
- };
353
+ const disposables = getElementDisposables(element);
545
354
 
546
- const engine = (element as any).__hypenEngine;
547
- if (engine) {
548
- engine.dispatchAction(actionName, payload);
549
- } else {
550
- console.warn(`[EventApplicator] No engine attached to element for onBlur`);
551
- }
552
- };
553
-
554
- (element as any).__hypenBlurListener = listener;
555
- element.addEventListener("blur", listener);
556
-
557
- console.log(`[EventApplicator] onBlur handler attached for action: ${actionName}`);
558
- },
559
-
560
- onMouseEnter: (element, value) => {
561
- console.log(`[EventApplicator] onMouseEnter called with value:`, value);
562
-
563
- const { actionName, payload: customPayload } = extractActionDetails(value);
564
-
565
- if (!actionName) {
566
- console.warn(`[EventApplicator] onMouseEnter value must be an action reference, got:`, value);
355
+ const eventKey = `longclick:${actionName}`;
356
+ if (getRegisteredEvents(element).has(eventKey)) {
567
357
  return;
568
358
  }
359
+ registerEvent(element, eventKey);
569
360
 
570
- // Remove existing listener if any
571
- const existingListener = (element as any).__hypenMouseEnterListener;
572
- if (existingListener) {
573
- element.removeEventListener("mouseenter", existingListener);
574
- }
575
-
576
- const listener = (event: MouseEvent) => {
577
- console.log(`🔥 [EventApplicator] onMouseEnter fired, dispatching action: ${actionName}`);
578
-
579
- const payload = Object.keys(customPayload).length > 0
580
- ? { ...customPayload }
581
- : {
582
- type: "mouseenter",
583
- timestamp: Date.now(),
584
- clientX: event.clientX,
585
- clientY: event.clientY,
586
- };
587
-
588
- const engine = (element as any).__hypenEngine;
589
- if (engine) {
590
- engine.dispatchAction(actionName, payload);
591
- } else {
592
- console.warn(`[EventApplicator] No engine attached to element for onMouseEnter`);
593
- }
594
- };
595
-
596
- (element as any).__hypenMouseEnterListener = listener;
597
- element.addEventListener("mouseenter", listener);
361
+ let longClickTimer: Disposable | null = null;
598
362
 
599
- console.log(`[EventApplicator] onMouseEnter handler attached for action: ${actionName}`);
600
- },
363
+ const downListener = (event: Event) => {
364
+ const pointerEvent = event as PointerEvent;
601
365
 
602
- onMouseLeave: (element, value) => {
603
- console.log(`[EventApplicator] onMouseLeave called with value:`, value);
366
+ longClickTimer = disposableTimeout(() => {
367
+ const payload =
368
+ Object.keys(customPayload).length > 0
369
+ ? { ...customPayload }
370
+ : {
371
+ type: "longclick",
372
+ timestamp: Date.now(),
373
+ clientX: pointerEvent.clientX,
374
+ clientY: pointerEvent.clientY,
375
+ };
604
376
 
605
- const { actionName, payload: customPayload } = extractActionDetails(value);
377
+ const engine = getEngine(element);
378
+ if (engine) {
379
+ engine.dispatchAction(actionName, payload);
380
+ }
606
381
 
607
- if (!actionName) {
608
- console.warn(`[EventApplicator] onMouseLeave value must be an action reference, got:`, value);
609
- return;
610
- }
382
+ longClickTimer = null;
383
+ }, thresholdMs);
384
+ };
611
385
 
612
- // Remove existing listener if any
613
- const existingListener = (element as any).__hypenMouseLeaveListener;
614
- if (existingListener) {
615
- element.removeEventListener("mouseleave", existingListener);
616
- }
386
+ const cancelListener = () => {
387
+ if (longClickTimer) {
388
+ longClickTimer.dispose();
389
+ longClickTimer = null;
390
+ }
391
+ };
617
392
 
618
- const listener = (event: MouseEvent) => {
619
- console.log(`🔥 [EventApplicator] onMouseLeave fired, dispatching action: ${actionName}`);
393
+ disposables.add(disposableListener(element, "pointerdown", downListener));
394
+ disposables.add(disposableListener(element, "pointerup", cancelListener));
395
+ disposables.add(disposableListener(element, "pointerleave", cancelListener));
396
+ disposables.addCallback(() => {
397
+ unregisterEvent(element, eventKey);
398
+ cancelListener();
399
+ });
400
+ };
401
+ }
620
402
 
621
- const payload = Object.keys(customPayload).length > 0
622
- ? { ...customPayload }
623
- : {
624
- type: "mouseleave",
625
- timestamp: Date.now(),
626
- clientX: event.clientX,
627
- clientY: event.clientY,
628
- };
403
+ // ============================================================================
404
+ // Payload Extractors
405
+ // ============================================================================
629
406
 
630
- const engine = (element as any).__hypenEngine;
631
- if (engine) {
632
- engine.dispatchAction(actionName, payload);
633
- } else {
634
- console.warn(`[EventApplicator] No engine attached to element for onMouseLeave`);
635
- }
636
- };
407
+ const inputPayload = (event: Event, element: HTMLElement): Record<string, unknown> => {
408
+ const target = element as HTMLInputElement | HTMLTextAreaElement;
409
+ return {
410
+ type: event.type,
411
+ timestamp: Date.now(),
412
+ value: target.value,
413
+ input: target.value,
414
+ };
415
+ };
637
416
 
638
- (element as any).__hypenMouseLeaveListener = listener;
639
- element.addEventListener("mouseleave", listener);
417
+ const scrollPayload = (_event: Event, element: HTMLElement): Record<string, unknown> => {
418
+ const scrollTop = element.scrollTop;
419
+ const scrollHeight = element.scrollHeight;
420
+ const clientHeight = element.clientHeight;
421
+ const scrollPercentage =
422
+ scrollHeight - clientHeight > 0
423
+ ? (scrollTop / (scrollHeight - clientHeight)) * 100
424
+ : 0;
640
425
 
641
- console.log(`[EventApplicator] onMouseLeave handler attached for action: ${actionName}`);
642
- },
426
+ const nearBottom =
427
+ scrollHeight - scrollTop - clientHeight < 100 || scrollPercentage > 90;
643
428
 
429
+ return {
430
+ type: "scroll",
431
+ timestamp: Date.now(),
432
+ scrollTop,
433
+ scrollLeft: element.scrollLeft,
434
+ scrollHeight,
435
+ scrollWidth: element.scrollWidth,
436
+ clientHeight,
437
+ clientWidth: element.clientWidth,
438
+ scrollPercentage: Math.round(scrollPercentage),
439
+ nearBottom,
440
+ atBottom: scrollHeight - scrollTop === clientHeight,
441
+ atTop: scrollTop === 0,
442
+ };
644
443
  };
645
444
 
646
- /**
647
- * Extract relevant data from an event
648
- */
649
- function extractEventData(event: Event, element: HTMLElement): any {
650
- const data: any = {
445
+ const focusPayload = (event: Event, element: HTMLElement): Record<string, unknown> => ({
446
+ type: event.type,
447
+ timestamp: Date.now(),
448
+ value: (element as HTMLInputElement).value ?? undefined,
449
+ });
450
+
451
+ const mousePayload = (event: Event, _element: HTMLElement): Record<string, unknown> => {
452
+ const mouseEvent = event as MouseEvent;
453
+ return {
651
454
  type: event.type,
652
455
  timestamp: Date.now(),
456
+ clientX: mouseEvent.clientX,
457
+ clientY: mouseEvent.clientY,
653
458
  };
459
+ };
654
460
 
655
- // Mouse events
656
- if (event instanceof MouseEvent) {
657
- data.clientX = event.clientX;
658
- data.clientY = event.clientY;
659
- data.button = event.button;
660
- }
461
+ // ============================================================================
462
+ // Event Handlers Export
463
+ // ============================================================================
464
+
465
+ export const eventHandlers: Record<string, ApplicatorHandler> = {
466
+ // Basic click/press
467
+ onClick: createEventHandler("click"),
468
+ onPress: createEventHandler("click"), // Alias for mobile-style naming
469
+
470
+ // Form events
471
+ onChange: createEventHandler("change"),
472
+ onSubmit: createEventHandler("submit", { preventDefault: true }),
473
+ onInput: createEventHandler("input", { extractPayload: inputPayload }),
661
474
 
662
475
  // Keyboard events
663
- if (event instanceof KeyboardEvent) {
664
- data.key = event.key;
665
- data.code = event.code;
666
- data.ctrlKey = event.ctrlKey;
667
- data.shiftKey = event.shiftKey;
668
- data.altKey = event.altKey;
669
- data.metaKey = event.metaKey;
670
- }
476
+ onKey: createKeyHandler("Enter"),
477
+ "onKey.key": (element: HTMLElement, value: unknown) => {
478
+ // Store the target key for the action handler to use
479
+ setKeyTarget(element, String(value));
480
+ },
481
+ "onKey.action": createKeyHandler("Enter"),
671
482
 
672
- // Input events (for form elements)
673
- if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
674
- data.value = element.value;
675
- }
483
+ // Scroll (throttled)
484
+ onScroll: createEventHandler("scroll", {
485
+ throttleMs: 100,
486
+ passive: true,
487
+ extractPayload: scrollPayload,
488
+ }),
676
489
 
677
- // Select events
678
- if (element instanceof HTMLSelectElement) {
679
- data.value = element.value;
680
- data.selectedIndex = element.selectedIndex;
681
- }
490
+ // Long click/press
491
+ onLongClick: createLongClickHandler(500),
682
492
 
683
- // Form events
684
- if (event.type === "submit" && element instanceof HTMLFormElement) {
685
- data.formData = new FormData(element);
686
- }
493
+ // Focus events
494
+ onFocus: createEventHandler("focus", { extractPayload: focusPayload }),
495
+ onBlur: createEventHandler("blur", { extractPayload: focusPayload }),
687
496
 
688
- return data;
689
- }
497
+ // Mouse hover events
498
+ onMouseEnter: createEventHandler("mouseenter", { extractPayload: mousePayload }),
499
+ onMouseLeave: createEventHandler("mouseleave", { extractPayload: mousePayload }),
500
+ };