From 1ac28e3f1cc4a3f118c87250c06424acf9594c60 Mon Sep 17 00:00:00 2001 From: Gary Bishop Date: Mon, 18 Sep 2023 13:06:40 -0400 Subject: [PATCH] Enable Patterns for Methods using Pointers This is a huge internal change and cleanup. It may break some designs but I have attempted to avoid that by choosing defaults. commit 5f233f93d73205b14cb5d6d7b08845c83dc2b560 Author: Gary Bishop Date: Mon Sep 18 13:03:29 2023 -0400 Default to no pattern for PointerMethods. commit e45272ef6c5fab1a507b00975f5a745d2e9d15b6 Author: Gary Bishop Date: Mon Sep 11 14:16:59 2023 -0400 Update default methods commit f2b40d4bf88ff41f9413e5d448b6e9b1f442b3b3 Author: Gary Bishop Date: Thu Sep 7 17:06:24 2023 -0400 Add preview to scripts. Much easier that the renaming hack I was using. commit b287c434231b79ac57a36d43a1b38d8877cc4f1a Author: Gary Bishop Date: Thu Sep 7 13:30:03 2023 -0400 Remove a console.log commit 7a45b3fc91782527327e973f36b8bdfc94c09dcb Author: Gary Bishop Date: Thu Sep 7 13:27:35 2023 -0400 Attach the Pattern to the Method. Now each Method can have a different Pattern if you wish. commit 7452d83e071b5cced5218b2f54f4cbc66dcbda2e Author: Gary Bishop Date: Thu Sep 7 13:27:08 2023 -0400 Exploit the new Field prop. commit ee14257f2075d641c7fac5e3e7b3a4b741ccc9fd Author: Gary Bishop Date: Thu Sep 7 13:25:00 2023 -0400 Extend Select to take a function This allows simplication elsewhere. I added several new Prop types to exploit this. commit cd1992ea2814b8d3d2501606814dd21ecbb27142 Author: Gary Bishop Date: Thu Sep 7 13:24:06 2023 -0400 Throw rather than returning null. I can't handle that case anyway. commit 4de7ca76f79357aff4b642da27bc2bb40db7c059 Author: Gary Bishop Date: Thu Sep 7 13:23:37 2023 -0400 Clear and type allFields commit 61bee055029e50aec68d2b749f3c5aaa07c733e6 Author: Gary Bishop Date: Thu Sep 7 13:22:58 2023 -0400 Tweak for diagnostics commit f798fdac8584d8258a5d208dbef097460622f194 Author: Gary Bishop Date: Thu Sep 7 13:22:32 2023 -0400 debounce is unused. commit 668723b684d334e04ccc9d5bf9d8da3e3a97fb1b Author: Gary Bishop Date: Sat Sep 2 15:45:24 2023 -0400 Fix a bug in the key handler commit a8a2eb3726f6d5a6c730e71bc1f493092fcdbe2f Author: Gary Bishop Date: Sat Sep 2 15:45:09 2023 -0400 Fix a bug in data.allFields commit 9160cb6cf04f7b6a5b9e59f4a7c910a19da00963 Merge: 8393b8e 92855d6 Author: Gary Bishop Date: Sat Sep 2 15:36:28 2023 -0400 Merge branch 'main' into pointergroups commit 8393b8ee903ce3c6a2e9d82910be9b0c7e719614 Author: Gary Bishop Date: Sat Aug 26 11:32:50 2023 -0400 Nits commit 64443e89a70ef9a772c0e9fe174a9e6ea8477279 Author: Gary Bishop Date: Sat Aug 26 11:18:54 2023 -0400 Factor out the state machine. commit 7c7cfc6e8ef4135467f0babc8fc8f5c50a68549c Author: Gary Bishop Date: Thu Aug 24 18:40:15 2023 -0400 Fix type error in data.js commit 76a9c8e333e6268121a37be67bdd4a0621b4a95d Author: Gary Bishop Date: Thu Aug 24 13:56:29 2023 -0400 Update default debounce values commit 195cf0bd76fd52aa8a9bf7198406189a88c9e2ef Author: Gary Bishop Date: Thu Aug 24 13:56:20 2023 -0400 Remove logging commit aaaa2909b2606ebf1878a2c033ad3e1b3c4f9509 Author: Gary Bishop Date: Thu Aug 24 11:02:16 2023 -0400 Upgrade and conditional debounce display commit 6d47a0e46b5a79e43cdc0d799de7d285f9552557 Author: Gary Bishop Date: Tue Aug 22 16:23:43 2023 -0400 Make handlers obey first one wins. This unifies handling events from the stream and I think is more efficient. commit d9fcf6aed8b5ca9b6f921d03b31a0fbd2f2a8bd5 Author: Gary Bishop Date: Tue Aug 22 10:11:46 2023 -0400 WIP: before control inversion commit e5801e1c420ce86f73930444a10a9dff4df0db00 Merge: 3807ef4 1a92ff4 Author: Gary Bishop Date: Mon Aug 21 10:50:26 2023 -0400 Merge branch 'main' into pointergroups commit 3807ef4735fc0c964ca0db49a536279376aadb3b Author: Gary Bishop Date: Tue Aug 15 13:58:05 2023 -0400 WIP commit f26f9d30dc41f82d48571432cdb47057c209b722 Author: Gary Bishop Date: Tue Aug 15 09:04:44 2023 -0400 WIP: Appears to be working. Still need to debounce pointer up/down. commit d31ab0880466d2cc7ce9162fbf1c5c82aef3574b Author: Gary Bishop Date: Tue Aug 8 09:00:21 2023 -0400 WIP: struggling with activation commit 43c558c482c537aa1cf692c07be253b6b5531004 Author: Gary Bishop Date: Wed Aug 2 09:01:52 2023 -0400 WIP: needs a rethink commit ee749f81dcdaca982bbae630aee833e16b431d61 Merge: dc43668 dcc7318 Author: Gary Bishop Date: Fri Jul 21 10:49:24 2023 -0400 Merge branch 'main' into pointergroups commit dc43668bd2aa1e03c9eaadb15a90cc2a4e87eaa7 Author: Gary Bishop Date: Thu Feb 23 13:37:11 2023 -0500 WIP commit 09c5abb3cc5af928df6b8d318d47980bfa51c930 Author: Gary Bishop Date: Wed Feb 22 11:11:14 2023 -0500 Update comments commit 501e267329b445304d9af2e04f5f54c4dd29ff0a Merge: 5575063 af01807 Author: Gary Bishop Date: Sun Feb 19 19:26:35 2023 -0500 Merge branch 'main' into pointergroups commit 557506390b38b57a8be5055620437e1a288a2b29 Merge: 413f083 0827d44 Author: Gary Bishop Date: Thu Jan 19 13:46:04 2023 -0500 Merge branch 'main' into pointergroups commit 413f0838f7fd9178efee373da67c67f89b189bc6 Author: Gary Bishop Date: Fri Jan 13 14:42:59 2023 -0500 WIP commit 8f7a306b57e35cb29081ba66d345ade3e8f7bfa4 Merge: e157f8d a36523c Author: Gary Bishop Date: Fri Jan 13 09:18:05 2023 -0500 Merge branch 'main' into pointergroups commit e157f8de016c89b0e69816db4179da22579f026a Merge: 155296e 06d87da Author: Gary Bishop Date: Wed Jan 11 13:44:00 2023 -0500 Merge branch 'main' into pointergroups commit 155296e327a5e7c430d1297fdae500187370ac60 Author: Gary Bishop Date: Tue Jan 10 14:44:04 2023 -0500 WIP on pointer groups Changes in Methods will be required. --- OS-DPI | 1 - components/access/index.js | 8 - components/access/method/defaultMethods.js | 29 +- components/access/method/index.js | 164 +++++++++-- components/access/method/keyHandler.js | 93 ++++--- components/access/method/pointerHandler.js | 301 ++++++++++++--------- components/access/method/responses.js | 45 ++- components/access/method/socketHandler.js | 126 ++++----- components/access/method/timerHandler.js | 29 +- components/access/pattern/index.js | 247 +++++++++++++---- components/grid.js | 2 +- components/props.js | 55 ++-- components/treebase.js | 4 +- data.js | 2 + jsconfig.json | 2 +- package.json | 4 +- thinking/pointergroups/jsconfig.json | 12 + thinking/pointergroups/pg.py | 40 ++- thinking/pointergroups/po.py | 9 +- thinking/pointergroups/tt.py | 40 +-- types/types.js | 16 ++ 21 files changed, 801 insertions(+), 428 deletions(-) delete mode 120000 OS-DPI create mode 100644 thinking/pointergroups/jsconfig.json diff --git a/OS-DPI b/OS-DPI deleted file mode 120000 index 53c37a16..00000000 --- a/OS-DPI +++ /dev/null @@ -1 +0,0 @@ -dist \ No newline at end of file diff --git a/components/access/index.js b/components/access/index.js index 37d24d34..ce5daf1b 100644 --- a/components/access/index.js +++ b/components/access/index.js @@ -1,11 +1,3 @@ -import { extender } from "proxy-pants"; - -/** Carry access data along with Events */ -const EventWrapProto = { - access: {}, -}; -export const EventWrap = extender(EventWrapProto); - /* Allow signaling that a button has changed since last render */ export let AccessChanged = false; diff --git a/components/access/method/defaultMethods.js b/components/access/method/defaultMethods.js index b8b1871a..cf99ca67 100644 --- a/components/access/method/defaultMethods.js +++ b/components/access/method/defaultMethods.js @@ -7,13 +7,16 @@ export default { props: { Name: "2 switch", Key: "idl6e14meiwzjdcquhgk9", + KeyDebounce: 0.1, + PointerEnterDebounce: 0, + PointerDownDebounce: 0, Active: "false", - Pattern: "idl83jjo4z0ibii6748fx", + Pattern: "DefaultPattern", }, children: [ { className: "KeyHandler", - props: { Signal: "keyup", Debounce: "0.1" }, + props: { Signal: "keyup" }, children: [ { className: "HandlerKeyCondition", @@ -34,7 +37,7 @@ export default { }, { className: "KeyHandler", - props: { Signal: "keyup", Debounce: "0.1" }, + props: { Signal: "keyup" }, children: [ { className: "HandlerKeyCondition", @@ -60,13 +63,16 @@ export default { props: { Name: "Pointer dwell", Key: "idl6wcdmjjkb48xmbxscn", + KeyDebounce: 0, + PointerEnterDebounce: 0.1, + PointerDownDebounce: 0.1, Active: "false", - Pattern: "idl84lw7z6km7dgni3tn", + Pattern: "idl83jg7qtj9wmyggtxf", }, children: [ { className: "PointerHandler", - props: { Signal: "pointerover", Debounce: "0.1" }, + props: { Signal: "pointerover" }, children: [ { className: "ResponderCue", @@ -85,7 +91,7 @@ export default { }, { className: "PointerHandler", - props: { Signal: "pointerout", Debounce: "0.1" }, + props: { Signal: "pointerout" }, children: [ { className: "ResponderClearCue", @@ -96,7 +102,7 @@ export default { }, { className: "PointerHandler", - props: { Signal: "pointerdown", Debounce: "0.1" }, + props: { Signal: "pointerdown" }, children: [ { className: "ResponderActivate", @@ -131,6 +137,9 @@ export default { className: "Method", props: { Name: "Mouse", + KeyDebounce: 0, + PointerEnterDebounce: 0, + PointerDownDebounce: 0, Key: "idl84ljjeoebyl94sow87", Active: "true", Pattern: "idl83jg7qtj9wmyggtxf", @@ -138,7 +147,7 @@ export default { children: [ { className: "PointerHandler", - props: { Signal: "pointerdown", Debounce: "0.01" }, + props: { Signal: "pointerdown" }, children: [ { className: "ResponderActivate", @@ -149,7 +158,7 @@ export default { }, { className: "PointerHandler", - props: { Signal: "pointerover", Debounce: "0.1" }, + props: { Signal: "pointerover" }, children: [ { className: "ResponderCue", @@ -160,7 +169,7 @@ export default { }, { className: "PointerHandler", - props: { Signal: "pointerout", Debounce: "0.1" }, + props: { Signal: "pointerout" }, children: [ { className: "ResponderClearCue", diff --git a/components/access/method/index.js b/components/access/method/index.js index faa94188..d24c4e6a 100644 --- a/components/access/method/index.js +++ b/components/access/method/index.js @@ -3,7 +3,6 @@ import { TreeBase, TreeBaseSwitchable } from "components/treebase"; import * as Props from "components/props"; import Globals from "app/globals"; import * as RxJs from "rxjs"; -import { EventWrap } from "../index"; // make sure the classes are registered import defaultMethods from "./defaultMethods"; import { DesignerPanel } from "components/designer"; @@ -53,11 +52,71 @@ export class MethodChooser extends DesignerPanel { .filter((child) => child.Active.value) .forEach((child) => child.refresh()); } + + /** + * Upgrade Methods + * @param {any} obj + * @returns {Object} + */ + static upgrade(obj) { + // Debounce moves up to the method from the individual handlers + // Take the maximum of all times for each category + if (obj.className != "MethodChooser") return obj; + + for (const method of obj.children) { + if (method.className != "Method") { + throw new Error("Invalid Method upgrade"); + } + if (!("KeyDebounce" in method.props)) { + let keyDebounce = 0; + let enterDebounce = 0; + let downDebounce = 0; + for (const handler of method.children) { + if (["PointerHandler", "KeyHandler"].includes(handler.className)) { + const debounce = parseFloat(handler.props.Debounce || "0"); + const signal = handler.props.Signal; + if (signal.startsWith("key")) { + keyDebounce = Math.max(keyDebounce, debounce); + } else if (["pointerover", "pointerout"].includes(signal)) { + enterDebounce = Math.max(enterDebounce, debounce); + } else if (["pointerdown", "pointerup"].includes(signal)) { + downDebounce = Math.max(downDebounce, debounce); + } + } + } + method.props.KeyDebounce = keyDebounce.toString(); + method.props.PointerEnterDebounce = enterDebounce.toString(); + method.props.PointerDownDebounce = downDebounce.toString(); + } + if (!("Pattern" in method.props)) { + /* guess the best pattern to use + * Prior to this upgrade PointerHandlers ignored the pattern. Now they don't. + * To avoid breaking Methods that using PointerHandlers I'm defaulting them + * to the NullPattern. This won't fix everything for sure but it shoudl help. + */ + let pattern = "DefaultPattern"; + if ( + method.children.some( + (/** @type {Object} */ handler) => + handler.className == "PointerHandler", + ) + ) { + pattern = "NullPattern"; + } + method.props.Pattern = pattern; + } + } + return obj; + } } TreeBase.register(MethodChooser, "MethodChooser"); export class Method extends TreeBase { Name = new Props.String("New method"); + Pattern = new Props.Pattern({ defaultValue: "DefaultPattern" }); + KeyDebounce = new Props.Float(0, { label: "Key down/up" }); + PointerEnterDebounce = new Props.Float(0, { label: "Pointer enter/leave" }); + PointerDownDebounce = new Props.Float(0, { label: "Pointer down/up" }); Key = new Props.UID(); Active = new Props.Boolean(false); @@ -71,6 +130,17 @@ export class Method extends TreeBase { open = false; + // Event streams from the devices + /** @type {Object>} */ + streams = {}; + + /** clear the pointerStream on any changes from below + * @param {TreeBase} _start + */ + onUpdate(_start) { + super.onUpdate(_start); + } + /** @type {(Handler | Timer)[]} */ children = []; @@ -79,7 +149,7 @@ export class Method extends TreeBase { * */ get timers() { return new Map( - this.filterChildren(Timer).map((child) => [child.Key.value, child]) + this.filterChildren(Timer).map((child) => [child.Key.value, child]), ); } @@ -89,7 +159,7 @@ export class Method extends TreeBase { this.filterChildren(Timer).map((timer) => [ timer.Key.value, timer.Name.value, - ]) + ]), ); } @@ -121,10 +191,31 @@ export class Method extends TreeBase { } settingsDetails() { - const { Name, Active } = this; + const { + Name, + Pattern, + Active, + KeyDebounce, + PointerEnterDebounce, + PointerDownDebounce, + } = this; const timers = [...this.timers.values()]; + // determine which debounce controls we should display + const handlerClasses = new Set( + this.handlers.map((handler) => handler.className), + ); + const keyDebounce = handlerClasses.has("KeyHandler") + ? [KeyDebounce.input()] + : []; + const pointerDebounce = handlerClasses.has("PointerHandler") + ? [PointerDownDebounce.input(), PointerEnterDebounce.input()] + : []; return html`
- ${Name.input()} ${Active.input()} + ${Name.input()} ${Active.input()} ${Pattern.input()} +
+ Debounce + ${keyDebounce} ${pointerDebounce} +
${timers.length > 0 ? html`
Timers @@ -147,15 +238,32 @@ export class Method extends TreeBase { * */ configure(stop$) { if (this.Active.value) { + this.streams = {}; for (const child of this.handlers) { child.configure(stop$); } + const streams = Object.values(this.streams); + if (streams.length > 0) { + const stream$ = RxJs.merge(...streams).pipe(RxJs.takeUntil(stop$)); + stream$.subscribe((e) => { + for (const handler of this.handlers) { + if (handler.test(e)) { + handler.respond(e); + return; + } + } + }); + } } } + get pattern() { + return Globals.patterns.patternFromKey(this.Pattern.value); + } + /** Refresh the pattern and other state on redraw */ refresh() { - Globals.patterns.activePattern.refresh(); + this.pattern.refresh(); } } TreeBase.register(Method, "Method"); @@ -165,7 +273,7 @@ class Timer extends TreeBase { Name = new Props.String("timer", { hiddenLabel: true }); Key = new Props.UID(); - /** @type {RxJs.Subject} */ + /** @type {RxJs.Subject} */ subject$ = new RxJs.Subject(); settings() { @@ -177,19 +285,18 @@ class Timer extends TreeBase {
`; } - /** @param {Event & { access: {}}} event */ + /** @param {EventLike} event */ start(event) { - const fakeEvent = /** @type {Event} */ ({ + const fakeEvent = /** @type {EventLike} */ ({ type: "timer", target: event.target, + access: event.access, }); - const tevent = EventWrap(fakeEvent); - tevent.access = event.access; - this.subject$.next(tevent); + this.subject$.next(fakeEvent); } cancel() { - const event = EventWrap(new Event("cancel")); + const event = { type: "cancel", target: null, timeStamp: 0 }; this.subject$.next(event); } } @@ -200,6 +307,14 @@ export class Handler extends TreeBase { /** @type {(HandlerCondition | HandlerKeyCondition | HandlerResponse)[]} */ children = []; + /** Return the method containing this Handler */ + get method() { + return /** @type {Method} */ (this.parent); + } + + // overridden in the derived classes + Signal = new Props.Select(); + get conditions() { return this.filterChildren(HandlerCondition); } @@ -212,6 +327,18 @@ export class Handler extends TreeBase { return this.filterChildren(HandlerResponse); } + /** + * Test the conditions for this handler + * @param {EventLike} event + * @returns {boolean} + */ + test(event) { + return ( + this.Signal.value == event.type && + this.conditions.every((condition) => condition.eval(event.access)) + ); + } + /** * @param {RxJs.Subject} _stop$ * */ @@ -219,7 +346,7 @@ export class Handler extends TreeBase { throw new TypeError("Must override configure"); } - /** @param {WrappedEvent} event */ + /** @param {EventLike} event */ respond(event) { // console.log("handler respond", event.type, this.responses); const method = this.nearestParent(Method); @@ -255,13 +382,18 @@ const allKeys = new Map([ ["ArrowDown", "Down Arrow"], ]); -export class HandlerKeyCondition extends TreeBase { +export class HandlerKeyCondition extends HandlerCondition { Key = new Props.Select(allKeys, { hiddenLabel: true }); settings() { const { Key } = this; return html`
${Key.input()}
`; } + + /** @param {Object} context */ + eval(context) { + return this.Key.value == context.key; + } } TreeBase.register(HandlerKeyCondition, "HandlerKeyCondition"); @@ -278,7 +410,7 @@ const ResponderTypeMap = new Map([ export class HandlerResponse extends TreeBaseSwitchable { Response = new Props.TypeSelect(ResponderTypeMap, { hiddenLabel: true }); - /** @param {Event & { access: Object }} event */ + /** @param {EventLike} event */ respond(event) { console.log("no response for", event); } diff --git a/components/access/method/keyHandler.js b/components/access/method/keyHandler.js index bfe6836e..6acd437d 100644 --- a/components/access/method/keyHandler.js +++ b/components/access/method/keyHandler.js @@ -1,8 +1,7 @@ import { TreeBase } from "components/treebase"; import * as Props from "components/props"; import { html } from "uhtml"; -import { EventWrap } from "../index"; -import { Handler } from "./index"; +import { Handler, HandlerKeyCondition } from "./index"; import * as RxJs from "rxjs"; const keySignals = new Map([ @@ -18,22 +17,23 @@ export class KeyHandler extends Handler { ]; Signal = new Props.Select(keySignals); - Debounce = new Props.Float(0.1); settings() { const { conditions, responses, keys } = this; - const { Signal, Debounce } = this; + const { Signal } = this; return html`
Key Handler - ${Signal.input()} ${Debounce.input()} + ${Signal.input()}
Keys ${this.unorderedChildren(keys)}
Conditions - ${this.unorderedChildren(conditions)} + ${this.unorderedChildren( + conditions.filter((c) => !(c instanceof HandlerKeyCondition)) + )}
Responses @@ -43,10 +43,16 @@ export class KeyHandler extends Handler { `; } - /** @param {RxJs.Subject} stop$ */ - configure(stop$) { + /** @param {RxJs.Subject} _stop$ */ + configure(_stop$) { + const method = this.method; + const streamName = "key"; + + // only create it once + if (method.streams[streamName]) return; + // construct debounced key event stream - const debounceInterval = this.Debounce.valueAsNumber * 1000; + const debounceInterval = method.KeyDebounce.valueAsNumber * 1000; const keyDown$ = /** @type RxJs.Observable */ ( RxJs.fromEvent(document, "keydown") ); @@ -85,37 +91,50 @@ export class KeyHandler extends Handler { // only output when the type changes RxJs.distinctUntilKeyChanged("type") ) - ) + ), + RxJs.map((e) => { + // add context info to event for use in the conditions and response + /** @type {EventLike} */ + let kw = { + type: e.type, + target: null, + timeStamp: e.timeStamp, + access: { + key: e.key, + altKey: e.altKey, + ctrlKey: e.ctrlKey, + metaKey: e.metaKey, + shiftKey: e.shiftKey, + eventType: e.type, + ...method.pattern.getCurrentAccess(), + }, + }; + return kw; + }) ) ); - let stream$; - const keys = this.keys.map((key) => key.Key.value); - stream$ = keyEvents$.pipe( - RxJs.filter( - (e) => - e.type == this.Signal.value && - (keys.length == 0 || keys.indexOf(e.key) >= 0) - ), - RxJs.map((e) => { - // add context info to event for use in the conditions and response - const kw = EventWrap(e); - kw.access = { - key: e.key, - altKey: e.altKey, - ctrlKey: e.ctrlKey, - metaKey: e.metaKey, - shiftKey: e.shiftKey, - eventType: e.type, - }; - return kw; - }) + method.streams[streamName] = keyEvents$; + } + + /** + * Test the conditions for this handler + * @param {EventLike} event + * @returns {boolean} + */ + test(event) { + const signal = this.Signal.value; + + // key conditions are OR'ed together + // Other conditions are AND'ed + const keys = this.keys; + const conditions = this.conditions.filter( + (condition) => !(condition instanceof HandlerKeyCondition) + ); + return ( + event.type == signal && + (keys.length == 0 || keys.some((key) => key.eval(event.access))) && + conditions.every((condition) => condition.eval(event.access)) ); - for (const condition of this.conditions) { - stream$ = stream$.pipe( - RxJs.filter((e) => condition.Condition.eval(e.access)) - ); - } - stream$.pipe(RxJs.takeUntil(stop$)).subscribe((e) => this.respond(e)); } } TreeBase.register(KeyHandler, "KeyHandler"); diff --git a/components/access/method/pointerHandler.js b/components/access/method/pointerHandler.js index 2a0edb02..91a83e95 100644 --- a/components/access/method/pointerHandler.js +++ b/components/access/method/pointerHandler.js @@ -1,8 +1,14 @@ +/** + * Handle pointer events integrated with Pattern.Groups + * + * TODO: we should be "over" the current button after activate. We are + * currently not until you leave the current button and return. + */ + import { TreeBase } from "components/treebase"; import { Handler } from "./index"; import * as Props from "components/props"; import { html } from "uhtml"; -import { EventWrap } from "../index"; import * as RxJs from "rxjs"; const pointerSignals = new Map([ @@ -16,11 +22,10 @@ export class PointerHandler extends Handler { allowedChildren = ["HandlerCondition", "HandlerResponse"]; Signal = new Props.Select(pointerSignals); - Debounce = new Props.Float(0.1); SkipOnRedraw = new Props.Boolean(false); settings() { - const { conditions, responses, Signal, Debounce } = this; + const { conditions, responses, Signal } = this; const skip = this.Signal.value == "pointerover" ? this.SkipOnRedraw.input() @@ -28,7 +33,7 @@ export class PointerHandler extends Handler { return html`
Pointer Handler - ${Signal.input()} ${Debounce.input()} ${skip} + ${Signal.input()} ${skip}
Conditions ${this.unorderedChildren(conditions)} @@ -41,146 +46,196 @@ export class PointerHandler extends Handler { `; } - /** @param {RxJs.Subject} stop$ */ - configure(stop$) { - const signal = this.Signal.value; + /** @param {RxJs.Subject} _ */ + configure(_) { + const method = this.method; + const streamName = "pointer"; + // only create it once + if (method.streams[streamName]) return; + + const pattern = method.pattern; + + const inOutThreshold = method.PointerEnterDebounce.valueAsNumber * 1000; + const upDownThreshold = method.PointerDownDebounce.valueAsNumber * 1000; - const debounceInterval = this.Debounce.valueAsNumber * 1000; - // construct pointer streams /** * Get the types correct * - * @param {Node} where * @param {string} event * @returns {RxJs.Observable} */ - function fromPointerEvent(where, event) { + function fromPointerEvent(event) { return /** @type {RxJs.Observable} */ ( - RxJs.fromEvent(where, event) + RxJs.fromEvent(document, event) ); } - const pointerDown$ = fromPointerEvent(document, "pointerdown"); - - // disable pointer capture - pointerDown$.pipe(RxJs.takeUntil(stop$)).subscribe( - /** @param {PointerEvent} x */ - (x) => - x.target instanceof Element && - x.target.hasPointerCapture(x.pointerId) && - x.target.releasePointerCapture(x.pointerId) + + const pointerDown$ = fromPointerEvent("pointerdown").pipe( + // disable pointer capture + RxJs.tap( + (x) => + x.target instanceof Element && + x.target.hasPointerCapture(x.pointerId) && + x.target.releasePointerCapture(x.pointerId) + ), + RxJs.throttleTime(upDownThreshold) ); - const pointerUp$ = fromPointerEvent(document, "pointerup"); - const pointerOver$ = fromPointerEvent(document, "pointerover"); - const pointerOut$ = fromPointerEvent(document, "pointerout"); + const pointerUp$ = fromPointerEvent("pointerup").pipe( + RxJs.throttleTime(upDownThreshold) + ); - /** - * Create a debounced pointer stream + /** @type {EventLike} */ + const None = { type: "none", target: null, timeStamp: 0 }; + + /** This function defines the State Machine that will be applied to the stream + * of events by the RxJs.scan operator. It takes this function and an initial state + * and produces a stream of states. On each cycle after the first the input state + * will be the output state from the previous cycle. + * + * Define the state for the machine * - * We use groupBy to create a stream for each target and then debounce the - * streams independently before merging them back together. The final - * distinctUntilKeyChanged prevents producing multiple events when the - * pointer leaves and re-enters in a short time. + * @typedef {Object} machineState + * @property {EventLike} current - the currently active target + * @property {EventLike} over - the element we're currently over + * @property {number} timeStamp - the time of the last event + * @property {Map} accumulators - total time spent over each element + * @property {EventLike[]} emittedEvents - events to pass along * - * @param {RxJs.Observable} in$ - * @param {RxJs.Observable} out$ - * @param {Number} interval + * @param {machineState} state + * @param {EventLike} event - the incoming pointer event + * @returns {machineState} */ - function debouncedPointer(in$, out$, interval) { - return in$.pipe( - RxJs.mergeWith(out$), - RxJs.filter( - (e) => - e.target instanceof HTMLButtonElement && - e.target.closest("div#UI") !== null - ), - RxJs.groupBy((e) => e.target), - RxJs.mergeMap(($group) => - $group.pipe( - RxJs.debounceTime(interval) - // RxJs.distinctUntilKeyChanged("type") - ) - ) - ); + function stateMachine( + { current, over, timeStamp, accumulators, emittedEvents }, + event + ) { + // whenever we emit an event the pattern might get changed in the response + // check here to see if the target is still the same + if (emittedEvents.length > 0 && over !== None) { + const newOver = pattern.remapEventTarget({ + ...over, + target: over.originalTarget, + }); + if (newOver.target !== over.target) { + // copy the accumulator to the new target + accumulators.set(newOver.target, accumulators.get(over.target) || 0); + // zero the old target + accumulators.set(over.target, 0); + // use this new target + over = newOver; + } + } + + // time since last event + const dt = event.timeStamp - timeStamp; + timeStamp = event.timeStamp; + // clear the emitted Events + emittedEvents = []; + // increment the time of the target we are over + let sum = accumulators.get(over.target) || 0; + sum += dt; + accumulators.set(over.target, sum); + const threshold = inOutThreshold; + // exceeding the threshold triggers production of events + if (sum > threshold) { + // clamp it at the threshold value + accumulators.set(over.target, threshold); + if (over.target != current.target) { + if (current !== None) { + emittedEvents.push({ ...current, type: "pointerout" }); + } + current = over; + if (current !== None) { + emittedEvents.push({ ...current, type: "pointerover" }); + // console.log("push pointerover", events); + } + } else { + current = over; + } + } + // decrement the other accumulators + for (let [target, value] of accumulators) { + if (target !== over.target) { + value -= dt; + if (value <= 0) { + // this should prevent keeping old ones alive + accumulators.delete(target); + } else { + accumulators.set(target, value); + } + } + } + if (event.type == "pointerover") { + over = pattern.remapEventTarget(event); + } else if (event.type == "pointerout") { + over = None; + } else if (event.type == "pointerdown" && current !== None) { + emittedEvents.push({ ...current, type: "pointerdown" }); + } else if (event.type == "pointerup" && current !== None) { + emittedEvents.push({ ...current, type: "pointerup" }); + } + return { + current, + over, + timeStamp, + accumulators, + emittedEvents, + }; } - const pointerOverOut$ = debouncedPointer( - pointerOver$, - pointerOut$, - debounceInterval - ); - const pointerDownUp$ = debouncedPointer( - pointerDown$, - pointerUp$, - debounceInterval - ); - // disable the context menu event for touch devices - RxJs.fromEvent(document, "contextmenu") - .pipe( - RxJs.filter( - (e) => - e.target instanceof HTMLButtonElement && - e.target.closest("div#UI") !== null - ), - RxJs.takeUntil(stop$) - ) - .subscribe((e) => e.preventDefault()); - - /** @type {RxJs.Observable} */ - let stream$ = pointerOverOut$.pipe( - RxJs.mergeWith(pointerDownUp$), - // RxJs.tap((e) => console.log("b", e.type)), + const pointerStream$ = pointerDown$.pipe( + // merge the streams + RxJs.mergeWith( + pointerUp$, + fromPointerEvent("pointerover"), + fromPointerEvent("pointerout"), + fromPointerEvent("contextmenu") + ), + // keep only events related to buttons within the UI RxJs.filter( - (e) => e.target instanceof HTMLButtonElement && !e.target.disabled + (e) => + e.target instanceof HTMLButtonElement && + e.target.closest("div#UI") !== null && + !e.target.disabled + ), + // kill contextmenu events + RxJs.tap((e) => e.type === "contextmenu" && e.preventDefault()), + + // Add the timer events + RxJs.mergeWith( + // I pulled 10ms out of my ear, would 20 or even 50 do? + RxJs.timer(10, 10).pipe(RxJs.map(() => new PointerEvent("tick"))) ), - RxJs.map((e) => { - const ew = EventWrap(e); - ew.access = { .../** @type {HTMLButtonElement} */ (e.target).dataset }; - ew.access.eventType = e.type; - return ew; - }) - // RxJs.tap((e) => console.log("a", e.type)) + // run the state machine + RxJs.scan(stateMachine, { + // the initial state + current: None, + over: None, + timeStamp: 0, + accumulators: new Map(), + emittedEvents: [], + }), + RxJs.filter((s) => s.emittedEvents.length > 0), + RxJs.mergeMap((state) => + RxJs.of( + ...state.emittedEvents.map((event) => { + /** @type {EventLike} */ + let w = { + ...event, + timeStamp: state.timeStamp, + access: event.access, + }; + w.access.eventType = event.type; + return w; + }) + ) + ), + // multicast the stream + RxJs.share() ); - /* I am killing the "pointerover" event that occurs when a button is replaced - * on a redraw if the user requests it. This avoids repeats when dwelling - * over a button causes a new "page" to be created. - * - * TODO: I bet there is a cleaner way to do this. - */ - const firstEvent = EventWrap(new PointerEvent("first")); - if (signal == "pointerover" && this.SkipOnRedraw.value) { - stream$ = stream$.pipe( - // a fake event to startup pairwise - RxJs.startWith(firstEvent), - // pair the events so I can compare them - RxJs.pairwise(), - // if we get a pair of pointerover events with different targets the page must have redrawn. - RxJs.filter( - ([first, second]) => - !( - first.type == "pointerover" && - second.type == "pointerover" && - first.target !== second.target - ) - ), - // undo the pairwise - RxJs.map(([_first, second]) => second) - ); - } - // only get the signal we want - stream$ = stream$.pipe(RxJs.filter((e) => e.type == signal)); - // apply the conditions - for (const condition of this.conditions) { - stream$ = stream$.pipe( - RxJs.filter((e) => condition.Condition.eval(e.access)) - ); - } - stream$ - .pipe( - // tap((event) => console.log("ph", event.type, event)), - RxJs.takeUntil(stop$) - ) - .subscribe((e) => this.respond(e)); + + method.streams[streamName] = pointerStream$; } } TreeBase.register(PointerHandler, "PointerHandler"); diff --git a/components/access/method/responses.js b/components/access/method/responses.js index 88baeebe..29f0acef 100644 --- a/components/access/method/responses.js +++ b/components/access/method/responses.js @@ -6,46 +6,34 @@ import { cueTarget, clearCues } from "../pattern"; class ResponderNext extends HandlerResponse { respond() { - Globals.patterns.activePattern.next(); + const method = this.nearestParent(Method); + if (!method) return; + method.pattern.next(); } } TreeBase.register(ResponderNext, "ResponderNext"); class ResponderActivate extends HandlerResponse { - /** @param {Event} event */ + /** @param {EventLike} event */ respond(event) { - if (Globals.patterns.activePattern.cued) { - Globals.patterns.activePattern.activate(); - } else if ( - (event instanceof PointerEvent || event.type == "timer") && - event.target instanceof HTMLButtonElement - ) { - const button = event.target; - const name = button.dataset.ComponentName; - if (button.hasAttribute("click")) { - button.click(); - } else if (name) { - Globals.actions.applyRules(name, "press", button.dataset); - } - } + const method = this.nearestParent(Method); + if (!method) return; + method.pattern.activate(event); } } TreeBase.register(ResponderActivate, "ResponderActivate"); class ResponderCue extends HandlerResponse { - Cue = new Props.Select(); + Cue = new Props.Cue(); subTemplate() { - return this.Cue.input(Globals.cues.cueMap); + return this.Cue.input(); } - /** @param {Event & { access: Object }} event */ + /** @param {EventLike} event */ respond(event) { - if (event.target instanceof HTMLButtonElement) { - clearCues(); - const button = event.target; - cueTarget(button, this.Cue.value); - } + // console.log("cue", event); + cueTarget(event.target, this.Cue.value); } } TreeBase.register(ResponderCue, "ResponderCue"); @@ -58,7 +46,7 @@ class ResponderClearCue extends HandlerResponse { TreeBase.register(ResponderClearCue, "ResponderClearCue"); class ResponderEmit extends HandlerResponse { - /** @param {Event & { access: Object }} event */ + /** @param {EventLike} event */ respond(event) { const method = this.nearestParent(Method); if (!method) return; @@ -68,17 +56,16 @@ class ResponderEmit extends HandlerResponse { TreeBase.register(ResponderEmit, "ResponderEmit"); class ResponderStartTimer extends HandlerResponse { - TimerName = new Props.Select([], { + TimerName = new Props.Select(() => this.nearestParent(Method).timerNames, { placeholder: "Choose a timer", hiddenLabel: true, }); subTemplate() { - const timerNames = this.nearestParent(Method)?.timerNames; - return this.TimerName.input(timerNames); + return this.TimerName.input(); } - /** @param {Event & { access: Object }} event */ + /** @param {EventLike} event */ respond(event) { const timer = this.nearestParent(Method)?.timer(this.TimerName.value); if (!timer) return; diff --git a/components/access/method/socketHandler.js b/components/access/method/socketHandler.js index c32485dc..ec72319d 100644 --- a/components/access/method/socketHandler.js +++ b/components/access/method/socketHandler.js @@ -4,7 +4,6 @@ import * as Props from "components/props"; import { html } from "uhtml"; import * as RxJs from "rxjs"; import { webSocket } from "rxjs/webSocket"; -import { EventWrap } from ".."; import Globals from "app/globals"; export class SocketHandler extends Handler { @@ -32,112 +31,89 @@ export class SocketHandler extends Handler { } init() { + // set the signal value + this.Signal.set("socket"); + // arrange to watch for state changes // TODO: figure out how to remove these or make them weak Globals.state.observe(() => { if (Globals.state.hasBeenUpdated(this.StateName.value)) { if (!this.socket) { // the connect wasn't successfully opened, try again - this.reconfigure(); - } - if (!this.live) { - // if the connection was shut down completion then resubscribe to reopen it. - this.subscribe(); + console.error("socket is not active"); + return; } - if (!this.socket || !this.live) return; // send the data over the websocket this.socket.next(Globals.state.values); } }); } - // true when the socket exists and subscribed - live = false; - /** The websocket wrapper object * @type {import("rxjs/webSocket").WebSocketSubject | undefined} */ socket = undefined; /** The stream of events from the websocket - * @type {RxJs.Observable | undefined} */ + * @type {RxJs.Observable | undefined} */ socket$ = undefined; - /** Save the stop$ subject so we can use it when reconfiguring - * @type {RxJs.Subject | undefined} */ - savedStop$ = undefined; + /** @param {RxJs.Subject} _stop$ */ + configure(_stop$) { + const method = this.method; + const streamName = "socket"; + // only create it once + if (method.streams[streamName]) return; - /** @param {RxJs.Subject} stop$ */ - configure(stop$) { - this.savedStop$ = stop$; - this.reconfigure(); - } - - reconfigure() { - if (!this.savedStop$) return; // keeping type checking happy - const { conditions } = this; // this is the socket object this.socket = webSocket(this.URL.value); + // this is the stream of events from it this.socket$ = this.socket.pipe( + RxJs.retry({ count: 10, delay: 5000 }), RxJs.map((msg) => { const event = new Event("socket"); - const wrapped = EventWrap(event); - wrapped.access = msg; + /** @type {EventLike} */ + const wrapped = { + type: "socket", + timeStamp: event.timeStamp, + access: msg, + target: null, + }; return wrapped; }), - RxJs.filter( - (e) => - conditions.length == 0 || - conditions.every((condition) => condition.Condition.eval(e.access)) - ), - RxJs.takeUntil(this.savedStop$) + RxJs.tap((e) => console.log("socket", e)) ); - this.subscribe(); + method.streams[streamName] = this.socket$; } - subscribe() { - if (!this.socket$) return; - this.live = true; - this.socket$.subscribe({ - next: (e) => { - /* Incoming data arrives here in the .access property. This code will filter any arrays of objects and - * include them in the dynamic data - */ - let dynamicRows = []; - const fields = []; - for (const [key, value] of Object.entries(e.access)) { - console.log(key, value); - if ( - Array.isArray(value) && - value.length > 0 && - typeof value[0] === "object" && - value[0] !== null - ) { - dynamicRows = dynamicRows.concat(value); - } else { - fields.push([key, value]); - } - } - e.access = Object.fromEntries(fields); - if (dynamicRows.length > 0) { - Globals.data.setDynamicRows(dynamicRows); - } - // pass incoming messages to the response - this.respond(e); - }, - error: (err) => { - // force a call to reconfigure - console.error("error", err); - this.live = false; - this.socket = undefined; - this.socket$ = undefined; - }, - complete: () => { - // mark that it is not live but it could be restarted - console.log("complete"); - this.live = false; - }, - }); + /** @param {EventLike} event */ + respond(event) { + console.log("socket respond", event.type); + + /* Incoming data arrives here in the .access property. This code will filter any arrays of objects and + * include them in the dynamic data + */ + let dynamicRows = []; + const fields = []; + for (const [key, value] of Object.entries(event.access)) { + console.log(key, value); + if ( + Array.isArray(value) && + value.length > 0 && + typeof value[0] === "object" && + value[0] !== null + ) { + dynamicRows = dynamicRows.concat(value); + } else { + fields.push([key, value]); + } + } + event.access = Object.fromEntries(fields); + if (dynamicRows.length > 0) { + Globals.data.setDynamicRows(dynamicRows); + } + // pass incoming messages to the response + super.respond(event); } } TreeBase.register(SocketHandler, "SocketHandler"); diff --git a/components/access/method/timerHandler.js b/components/access/method/timerHandler.js index feb76adb..b5e3ff92 100644 --- a/components/access/method/timerHandler.js +++ b/components/access/method/timerHandler.js @@ -36,21 +36,26 @@ export class TimerHandler extends Handler { `; } - /** @param {RxJs.Subject} stop$ */ - configure(stop$) { - const timer = this.nearestParent(Method)?.timer(this.TimerName.value); + /** @param {RxJs.Subject} _stop$ */ + configure(_stop$) { + const method = this.method; + const timerName = this.TimerName.value; + // there could be multiple timers active at once + const streamName = `timer-${timerName}`; + // only create it once + if (method.streams[streamName]) return; + + const timer = method.timer(timerName); if (!timer) return; + const delayTime = 1000 * timer.Interval.valueAsNumber; - timer.subject$ - .pipe( - RxJs.switchMap((event) => - event.type == "cancel" - ? RxJs.EMPTY - : RxJs.of(event).pipe(RxJs.delay(delayTime)) - ), - RxJs.takeUntil(stop$) + method.streams[streamName] = timer.subject$.pipe( + RxJs.switchMap((event) => + event.type == "cancel" + ? RxJs.EMPTY + : RxJs.of(event).pipe(RxJs.delay(delayTime)) ) - .subscribe((e) => this.respond(e)); + ); } } TreeBase.register(TimerHandler, "TimerHandler"); diff --git a/components/access/pattern/index.js b/components/access/pattern/index.js index 17211675..40552d81 100644 --- a/components/access/pattern/index.js +++ b/components/access/pattern/index.js @@ -7,13 +7,11 @@ import defaultPatterns from "./defaultPatterns"; import { DesignerPanel } from "components/designer"; import { toggleIndicator } from "app/components/helpers"; -/** @typedef {HTMLButtonElement | Group} Target */ - /** @param {Target} target - * @param {string} value */ -export function cueTarget(target, value) { + * @param {string} defaultValue */ +export function cueTarget(target, defaultValue) { if (target instanceof HTMLButtonElement) { - target.setAttribute("cue", value); + target.setAttribute("cue", defaultValue); const video = target.querySelector("video"); if (video && !video.hasAttribute("autoplay")) { if (video.hasAttribute("muted")) video.muted = true; @@ -26,8 +24,8 @@ export function cueTarget(target, value) { }); } } - } else { - target.cue(value); + } else if (target instanceof Group) { + target.cue(); } } @@ -54,11 +52,11 @@ export class Group { constructor(members, props) { /** @type {Target[]} */ this.members = members; - this.groupProps = props; + this.access = props; } get length() { - return this.members.length * +this.groupProps.Cycles; + return this.members.length * +this.access.Cycles; } /** @param {Number} index */ @@ -70,12 +68,32 @@ export class Group { } } - /** @param {string} _ */ - cue(_) { - // console.log("cue group", this.members); + /** @param {string} value */ + cue(value = "") { + if (!value) { + value = this.access.Cue; + } + // console.log("cue group", this); for (const member of this.members) { - cueTarget(member, this.groupProps.Cue); + if (member instanceof HTMLButtonElement) cueTarget(member, value); + else if (member instanceof Group) member.cue(value); + } + } + + /** Test if this group contains a button return the top-level index if so, -1 if not + * @param {HTMLButtonElement} button + * @returns {number} + */ + contains(button) { + for (let i = 0; i < this.members.length; i++) { + const member = this.members[i]; + if ( + member === button || + (member instanceof Group && member.contains(button) >= 0) + ) + return i; } + return -1; } } @@ -117,6 +135,33 @@ export class PatternList extends DesignerPanel { this.children.find((child) => child.Active.value) || this.children[0] ); } + + get patternMap() { + /** @type {[string,string][]} */ + const entries = this.children.map((child) => [ + child.Key.value, + child.Name.value, + ]); + entries.unshift(["DefaultPattern", "Default Pattern"]); + entries.unshift(["NullPattern", "No Pattern"]); + return new Map(entries); + } + + /** + * return the pattern given its key + * @param {string} key + */ + patternFromKey(key) { + let result; + if (key === "NullPattern") { + return nullPatternManager; + } + result = this.children.find((pattern) => pattern.Key.value == key); + if (!result) { + result = this.activePattern; + } + return result; + } } TreeBase.register(PatternList, "PatternList"); @@ -139,24 +184,13 @@ export class PatternManager extends PatternBase { // props Cycles = new Props.Integer(2, { min: 1 }); - Cue = new Props.Select([], { defaultValue: "DefaultCue" }); + Cue = new Props.Cue({ defaultValue: "DefaultCue" }); Name = new Props.String("a pattern"); Key = new Props.UID(); - Active = new Props.OneOfGroup(false, { name: "pattern-active" }); - - // settings() { - // const { Cycles, Cue, Name } = this; - // return html` - //
- // ${Name.value} - // ${Name.input()} ${Cycles.input()} ${Cue.input(Globals.cues.cueMap)} - //
- // Details - // ${this.orderedChildren()} - //
- //
- // `; - // } + Active = new Props.OneOfGroup(false, { + name: "pattern-active", + label: "Default", + }); settingsSummary() { const { Name, Active } = this; @@ -169,8 +203,8 @@ export class PatternManager extends PatternBase { const { Cycles, Cue, Name, Active } = this; return html`
- ${Name.input()} ${Active.input()} ${Cycles.input()} - ${Cue.input(Globals.cues.cueMap)} ${this.orderedChildren()} + ${Name.input()} ${Active.input()} ${Cycles.input()} ${Cue.input()} + ${this.orderedChildren()}
`; } @@ -225,9 +259,9 @@ export class PatternManager extends PatternBase { members = buttons; } this.targets = new Group(members, this.props); + // console.log("refresh", this.targets); this.stack = [{ group: this.targets, index: -1 }]; this.cue(); - // console.log("refresh", this); } /** @@ -257,8 +291,31 @@ export class PatternManager extends PatternBase { this.cue(); } - activate() { - // console.log("activate"); + /** @param {EventLike} event */ + activate(event) { + const target = event.target; + // console.log("activate", event); + if (target) { + // adjust the stack to accomodate the target + while (true) { + const top = this.stack[0]; + const newIndex = top.group.members.indexOf(target); + if (newIndex >= 0) { + top.index = newIndex; + // console.log("set index", top.index); + break; + } + if (this.stack.length == 1) { + top.index = 0; + // console.log("not found"); + break; + } else { + this.stack.shift(); + // console.log("pop stack"); + } + } + } + // console.log("stack", this.stack); let current = this.current; if (!current) return; if (current instanceof Group) { @@ -266,15 +323,18 @@ export class PatternManager extends PatternBase { while (current.length == 1 && current.members[0] instanceof Group) { current = current.members[0]; } - this.stack.unshift({ group: current, index: 0 }); + // I need to work out the index here. Should be the group under the pointer + this.stack.unshift({ group: current, index: event?.groupIndex || 0 }); // console.log("push stack", this.current, this.stack); - } else if (current.hasAttribute("click")) { - current.click(); - } else { - const name = current.dataset.ComponentName; - // console.log("activate button", current); - // console.log("applyRules", name, current.access); - Globals.actions.applyRules(name || "", "press", current.dataset); + } else if (current instanceof HTMLButtonElement) { + if (current.hasAttribute("click")) { + current.click(); + } else { + const name = current.dataset.ComponentName; + // console.log("activate button", current); + // console.log("applyRules", name, current.access); + Globals.actions.applyRules(name || "", "press", current.dataset); + } } this.cue(); } @@ -287,19 +347,90 @@ export class PatternManager extends PatternBase { cue() { this.clearCue(); const current = this.current; - // console.log("cue current", current); + // console.log("cue current", current); if (!current) return; this.cued = true; cueTarget(current, this.Cue.value); } + + /** Return the access info for current + */ + getCurrentAccess() { + const current = this.current; + if (!current) return {}; + if (current instanceof HTMLButtonElement) { + return current.dataset; + } else if (current instanceof Group) { + return { ...current.access }; + } + return {}; + } + + /** Map the event target to a group + * @param {EventLike} event + * @returns {EventLike} + */ + remapEventTarget(event) { + event = { + type: event.type, + target: event.target, + timeStamp: event.timeStamp, + }; + if (event.target instanceof HTMLButtonElement) { + event.access = event.target.dataset; + } + if (!event.target) return event; + // console.log("ret", this.stack); + event.originalTarget = event.target; + for (let level = 0; level < this.stack.length; level++) { + const group = this.stack[level].group; + const members = group.members; + // first scan to see if the element is top level in this group + let index = members.indexOf(event.target); + if (index >= 0) { + if (level === 0) { + // console.log("A", event); + return event; + } else { + // console.log("B", index); + return { + ...event, + target: group, + groupIndex: index, + access: { ...event.access, ...group.access }, + }; + } + } else if (event.target instanceof HTMLButtonElement) { + // otherwise check to see if any group members contain it + for (index = 0; index < members.length; index++) { + const member = members[index]; + if (member instanceof Group) { + let i = member.contains(event.target); + if (i >= 0) { + // console.log("C", i); + return { + ...event, + target: member, + groupIndex: i, + access: { ...event.access, ...member.access }, + }; + } + } + } + } + } + return event; + } } PatternBase.register(PatternManager, "PatternManager"); +const nullPatternManager = TreeBase.create(PatternManager); + export class PatternGroup extends PatternBase { // props Name = new Props.String(""); Cycles = new Props.Integer(2, { min: 1 }); - Cue = new Props.Select([], { defaultValue: "DefaultCue" }); + Cue = new Props.Cue({ defaultValue: "DefaultCue" }); allowedChildren = ["PatternGroup", "PatternSelector"]; @@ -307,8 +438,7 @@ export class PatternGroup extends PatternBase { const { Name, Cycles, Cue } = this; return html`
Group: ${Name.value} - ${Name.input()} ${Cycles.input()} ${Cue.input(Globals.cues.cueMap)} - ${this.orderedChildren()} + ${Name.input()} ${Cycles.input()} ${Cue.input()} ${this.orderedChildren()}
`; } @@ -353,7 +483,7 @@ class PatternSelector extends PatternBase { apply(input) { return this.children.reduce( (previous, operator) => operator.apply(previous), - input + input, ); } } @@ -376,12 +506,12 @@ class Filter extends PatternBase { return input .map( (/** @type {Group} */ group) => - new Group(this.apply(group.members), group.groupProps) + new Group(this.apply(group.members), group.access), ) .filter((target) => target.length > 0); } else { return input.filter((/** @type {HTMLButtonElement} */ button) => - this.Filter.eval(button.dataset) + this.Filter.eval(button.dataset), ); } } @@ -410,13 +540,13 @@ class OrderBy extends PatternBase { return input .map( (/** @type {Group} */ group) => - new Group(this.apply(group.members), group.groupProps) + new Group(this.apply(group.members), group.access), ) .filter((target) => target.length > 0); } else { const key = this.OrderBy.value.slice(1); return [.../** @type {HTMLButtonElement[]} */ (input)].sort((a, b) => - comparator.compare(a.dataset[key] || "", b.dataset[key] || "") + comparator.compare(a.dataset[key] || "", b.dataset[key] || ""), ); } } @@ -426,7 +556,7 @@ PatternBase.register(OrderBy, "OrderBy"); class GroupBy extends PatternBase { GroupBy = new Props.Field(); Name = new Props.String(""); - Cue = new Props.Select([], { defaultValue: "DefaultCue" }); + Cue = new Props.Cue({ defaultValue: "DefaultCue" }); Cycles = new Props.Integer(2); settings() { const { GroupBy, Name, Cue, Cycles } = this; @@ -439,8 +569,7 @@ class GroupBy extends PatternBase { ]), ]); return html`
- ${GroupBy.input(fields)} ${Name.input()} ${Cue.input(Globals.cues.cueMap)} - ${Cycles.input()} + ${GroupBy.input(fields)} ${Name.input()} ${Cue.input()} ${Cycles.input()}
`; } /** @@ -454,11 +583,11 @@ class GroupBy extends PatternBase { return input .map( (/** @type {Group} */ group) => - new Group(this.apply(group.members), group.groupProps) + new Group(this.apply(group.members), group.access), ) .filter((target) => target.length > 0); } else { - const { GroupBy, ...props } = this.props; + const { GroupBy, Name, ...props } = this.props; const key = GroupBy.slice(1); const result = []; const groupMap = new Map(); @@ -470,7 +599,11 @@ class GroupBy extends PatternBase { let group = groupMap.get(k); if (!group) { // no group, create one and add it to the map and the result - group = new Group([button], props); + group = new Group([button], { + GroupName: Name.replace(GroupBy, k), + [key]: k, + ...props, + }); groupMap.set(k, group); result.push(group); } else { diff --git a/components/grid.js b/components/grid.js index 582e49a4..ff21aaf7 100644 --- a/components/grid.js +++ b/components/grid.js @@ -271,7 +271,7 @@ class Grid extends TreeBase { TreeBase.register(Grid, "Grid"); export class GridFilter extends TreeBase { - field = new Props.Field([], { hiddenLabel: true }); + field = new Props.Field({ hiddenLabel: true }); operator = new Props.Select(Object.keys(comparators), { hiddenLabel: true }); value = new Props.String("", { hiddenLabel: true }); } diff --git a/components/props.js b/components/props.js index 82d94d73..bebc34bc 100644 --- a/components/props.js +++ b/components/props.js @@ -96,10 +96,13 @@ export class Prop { } } -/** @param {string[] | Map} arrayOrMap +/** @param {string[] | Map | function():Map} arrayOrMap * @returns Map */ export function toMap(arrayOrMap) { + if (arrayOrMap instanceof Function) { + return arrayOrMap(); + } if (Array.isArray(arrayOrMap)) { return new Map(arrayOrMap.map((item) => [item, item])); } @@ -108,20 +111,19 @@ export function toMap(arrayOrMap) { export class Select extends Prop { /** - * @param {string[] | Map} choices + * @param {string[] | Map | function():Map} choices * @param {PropOptions} options */ constructor(choices = [], options = {}) { super(options); - /** @type {Map} */ - this.choices = toMap(choices); + this.choices = choices; this.value = ""; } /** @param {Map | null} choices */ input(choices = null) { if (!choices) { - choices = this.choices; + choices = toMap(this.choices); } this.value = this.value || this.options.defaultValue || ""; return this.labeled(html`