diff --git a/README.md b/README.md index 09872591..e1038da3 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ You can see a few simple and rough demos of the current capabilities by followin - Contact-like utterance-based system - Start a new design -**These demos are known to not work in Safari.** Please use Google Chrome or one if its variants. +**These demos work in Safari 17+ but not in earlier versions.** In these demos you can switch between the developer mode and full-screen user-interface pressing the **Alt** or **Option** key followed by the **d** key. diff --git a/examples/updated/grid_ex_2.osdpi b/examples/updated/grid_ex_2.osdpi index 88936d26..d5ab09fd 100644 Binary files a/examples/updated/grid_ex_2.osdpi and b/examples/updated/grid_ex_2.osdpi differ diff --git a/examples/updated/keyboard_predict_ex_1.osdpi b/examples/updated/keyboard_predict_ex_1.osdpi index 60178652..a75414bb 100644 Binary files a/examples/updated/keyboard_predict_ex_1.osdpi and b/examples/updated/keyboard_predict_ex_1.osdpi differ diff --git a/examples/updated/utterance_Contact.osdpi b/examples/updated/utterance_Contact.osdpi index 6c50307d..0245e3ab 100644 Binary files a/examples/updated/utterance_Contact.osdpi and b/examples/updated/utterance_Contact.osdpi differ diff --git a/src/.rgignore b/src/.rgignore new file mode 100644 index 00000000..70af00a1 --- /dev/null +++ b/src/.rgignore @@ -0,0 +1,2 @@ +thinking/* +**/tracky-mouse/** diff --git a/src/UHTML.js b/src/UHTML.js new file mode 100644 index 00000000..40af8555 --- /dev/null +++ b/src/UHTML.js @@ -0,0 +1,84 @@ +import { html as _html } from "uhtml"; + +export { render } from "uhtml"; + +const typeMap = new WeakMap(); + +/** @param {TemplateStringsArray} strings + * @param {any[]} args + */ +export function html(strings, ...args) { + let types = typeMap.get(strings); + if (!types) { + types = args.map((arg) => getTypeOf(arg)); + typeMap.set(strings, types); + } + + if (!strings[0].match(/\s* { + const string = strings[index]; + if (!string.endsWith("=") && !string.endsWith('="')) { + // must be a content node + if (arg === null) { + throw new Error(`html arg after ${string} is null`); + } + if (arg === undefined) { + throw new Error(`html arg after ${string} is undefined`); + } + const atype = getTypeOf(arg); + if (atype != types[index]) { + const t = types[index]; + if ( + !atype.startsWith("Array") || + !t.startsWith("Array") || + !(atype.endsWith("empty") || t.endsWith("empty")) + ) + throw new Error( + `type of arg after ${string} changed from ${types[index]} to ${atype}`, + ); + } + } else { + // must be an attribute + const atype = getTypeOf(arg); + if ( + atype != types[index] && + atype != "undefined" && + types[index] != "undefined" && + atype != "null" && + types[index] != "null" + ) { + throw new Error( + `attribute ${string} changed from ${types[index]} to ${atype}`, + ); + } + } + }); + return _html(strings, ...args); +} + +/** @param {any} arg + * @returns {string} */ +function getTypeOf(arg) { + const t = typeof arg; + if (t != "object") return t; + if (arg == null) return "null"; + + if (Array.isArray(arg)) { + if (arg.length) { + const ts = arg.map((a) => getTypeOf(a)); + if (!ts.every((t) => t == t[0])) { + return `Array of ${ts[0]}`; + } else { + console.error("array", ts); + throw new Error("Array elements of different types"); + } + } else return `Array empty`; + } + if (arg.constructor.name == "Hole") { + return "Hole"; + } + return "object"; +} diff --git a/src/components/README.md b/src/components/README.md index 6345d329..9069dcb6 100644 --- a/src/components/README.md +++ b/src/components/README.md @@ -32,7 +32,6 @@ monitor.js: Show internal state in the designer. toolbar.js: The designer toolbar. treebase.js: Nearly every component is derived from TreeBase. wait.js: Display "Please Wait" during asynchronous operations that take too long. -welcome.js: Display the welcome screen. ## Helpers diff --git a/src/components/access/cues/defaultCues.js b/src/components/access/cues/defaultCues.js index bbcd644b..ba26606c 100644 --- a/src/components/access/cues/defaultCues.js +++ b/src/components/access/cues/defaultCues.js @@ -14,7 +14,7 @@ export default { Name: "red overlay", Key: "idl7w16hghqop9hcgn95", CueType: "CueOverlay", - Default: true, + Default: "true", Color: "red", Opacity: "0.2", }, @@ -26,11 +26,11 @@ export default { Name: "fill", Key: "idl7ysqw4agxg63qvx4j5", CueType: "CueFill", - Default: false, + Default: "false", Color: "#7BAFD4", Opacity: "0.3", Direction: "top", - Repeat: false, + Repeat: "false", }, children: [], }, @@ -40,9 +40,9 @@ export default { Name: "circle", Key: "idl7ythslqew02w4pom29", CueType: "CueCircle", - Default: false, + Default: "false", Color: "#7BAFD4", - Opacity: 0.7, + Opacity: "0.7", }, children: [], }, @@ -52,7 +52,7 @@ export default { Name: "yellow overlay using CSS", Key: "idl7qm4cs28fh2ogf4ni", CueType: "CueCss", - Default: false, + Default: "false", Code: `button[cue="$Key"] { position: relative; border-color: yellow; diff --git a/src/components/access/cues/index.js b/src/components/access/cues/index.js index 74bac0a7..84e14c90 100644 --- a/src/components/access/cues/index.js +++ b/src/components/access/cues/index.js @@ -68,7 +68,10 @@ export class CueList extends DesignerPanel { // update any CueCss entries to the new style interpolation if (obj.className == "CueList") { for (const child of obj.children) { - if (child.className == "CueCss") { + if ( + child.className == "CueCss" && + typeof child.props.Code === "string" + ) { child.props.Code = child.props.Code.replaceAll("{{Key}}", "$Key"); } } diff --git a/src/components/access/method/index.js b/src/components/access/method/index.js index f22c349c..8028e161 100644 --- a/src/components/access/method/index.js +++ b/src/components/access/method/index.js @@ -24,11 +24,6 @@ export class MethodChooser extends DesignerPanel { static tableName = "method"; static defaultValue = defaultMethods; - onUpdate() { - super.onUpdate(); - this.configure(); - } - configure() { // tear down the old configuration if any this.stop(); @@ -118,7 +113,7 @@ export class Method extends TreeBase { 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.OneOfGroup(false, { group: "ActiveMethod" }); + Active = new Props.Boolean(false); allowedChildren = [ "Timer", @@ -249,7 +244,7 @@ export class Method extends TreeBase { if (this.Active.value) { this.streams = {}; for (const child of this.handlers) { - child.configure(stop$); + child.configure(); } const streams = Object.values(this.streams); if (streams.length > 0) { @@ -272,7 +267,7 @@ export class Method extends TreeBase { /** Refresh the pattern and other state on redraw */ refresh() { - this.pattern.refresh(); + if (this.pattern) this.pattern.refresh(); } } TreeBase.register(Method, "Method"); @@ -350,10 +345,7 @@ export class Handler extends TreeBase { ); } - /** - * @param {RxJs.Subject} _stop$ - * */ - configure(_stop$) { + configure() { throw new TypeError("Must override configure"); } @@ -403,7 +395,11 @@ export class HandlerKeyCondition extends HandlerCondition { /** @param {EvalContext} context */ eval(context) { - return this.Key.value == context.data.key; + return !!( + context.data && + context.data.key && + this.Key.value == context.data.key + ); } } TreeBase.register(HandlerKeyCondition, "HandlerKeyCondition"); diff --git a/src/components/access/method/keyHandler.js b/src/components/access/method/keyHandler.js index c16901e2..8b11e87e 100644 --- a/src/components/access/method/keyHandler.js +++ b/src/components/access/method/keyHandler.js @@ -43,8 +43,7 @@ export class KeyHandler extends Handler { `; } - /** @param {RxJs.Subject} _stop$ */ - configure(_stop$) { + configure() { const method = this.method; const streamName = "key"; diff --git a/src/components/access/method/pointerHandler.js b/src/components/access/method/pointerHandler.js index c7c48ce4..8266e6cd 100644 --- a/src/components/access/method/pointerHandler.js +++ b/src/components/access/method/pointerHandler.js @@ -41,8 +41,7 @@ export class PointerHandler extends Handler { `; } - /** @param {RxJs.Subject} _ */ - configure(_) { + configure() { const method = this.method; const streamName = "pointer"; // only create it once @@ -50,6 +49,8 @@ export class PointerHandler extends Handler { const pattern = method.pattern; + if (!pattern) return; + const inOutThreshold = method.PointerEnterDebounce.value * 1000; const upDownThreshold = method.PointerDownDebounce.value * 1000; @@ -110,7 +111,7 @@ export class PointerHandler extends Handler { if (emittedEvents.length > 0 && over !== None) { const newOver = pattern.remapEventTarget({ ...over, - target: over.originalTarget, + target: over.originalTarget || null, }); if (newOver.target !== over.target) { // copy the accumulator to the new target @@ -218,15 +219,12 @@ export class PointerHandler extends Handler { let w = { ...event, timeStamp: state.timeStamp, - access: event.access, + access: { ...event.access, eventType: event.type }, }; - w.access.eventType = event.type; return w; }), ), ), - // multicast the stream - RxJs.share(), ); method.streams[streamName] = pointerStream$; diff --git a/src/components/access/method/socketHandler.js b/src/components/access/method/socketHandler.js index ec74a5cd..660cb77a 100644 --- a/src/components/access/method/socketHandler.js +++ b/src/components/access/method/socketHandler.js @@ -63,8 +63,7 @@ export class SocketHandler extends Handler { * @type {RxJs.Observable | undefined} */ socket$ = undefined; - /** @param {RxJs.Subject} _stop$ */ - configure(_stop$) { + configure() { const method = this.method; const streamName = "socket"; // only create it once @@ -101,7 +100,7 @@ export class SocketHandler extends Handler { */ let dynamicRows = []; const fields = []; - for (const [key, value] of Object.entries(event.access)) { + for (const [key, value] of Object.entries(event.access || {})) { console.log(key, value); if ( Array.isArray(value) && diff --git a/src/components/access/method/timerHandler.js b/src/components/access/method/timerHandler.js index 8924fe12..3187f6e9 100644 --- a/src/components/access/method/timerHandler.js +++ b/src/components/access/method/timerHandler.js @@ -36,8 +36,7 @@ export class TimerHandler extends Handler { `; } - /** @param {RxJs.Subject} _stop$ */ - configure(_stop$) { + configure() { const method = this.method; const timerName = this.TimerName.value; // there could be multiple timers active at once diff --git a/src/components/access/pattern/index.js b/src/components/access/pattern/index.js index c8a6fef5..d7cee0fe 100644 --- a/src/components/access/pattern/index.js +++ b/src/components/access/pattern/index.js @@ -192,6 +192,7 @@ export class PatternManager extends PatternBase { group: "pattern-active", label: "Default", }); + StartVisible = new Props.Boolean(false); settingsSummary() { const { Name, Active } = this; @@ -201,11 +202,12 @@ export class PatternManager extends PatternBase { } settingsDetails() { - const { Cue, Name, Active } = this; + const { Cue, Name, Active, StartVisible } = this; return [ html`
${Name.input()} ${Active.input()} ${Cue.input()} + ${StartVisible.input()}
`; } diff --git a/src/components/designer.js b/src/components/designer.js index bebb7e16..0bca7874 100644 --- a/src/components/designer.js +++ b/src/components/designer.js @@ -6,6 +6,7 @@ import Globals from "app/globals"; import { TreeBase } from "./treebase"; import { callAfterRender } from "app/render"; import db from "app/db"; +import { ChangeStack } from "./undo"; export class Designer extends TreeBase { stateName = new Props.String("$tabControl"); @@ -113,7 +114,7 @@ export class Designer extends TreeBase { } }; - /** @returns {TreeBase | null} */ + /** @returns {TreeBase | undefined } */ get selectedComponent() { // Figure out which tab is active const { designer } = Globals; @@ -121,17 +122,30 @@ export class Designer extends TreeBase { // Ask that tab which component is focused if (!panel?.lastFocused) { - console.log("no lastFocused"); - return null; + return undefined; } const component = TreeBase.componentFromId(panel.lastFocused); if (!component) { console.log("no component"); - return null; + return undefined; } return component; } + /** @param {string} targetId */ + focusOn(targetId) { + let elem = document.getElementById(targetId); + if (!elem) { + // perhaps this one is embeded, look for something that starts with it + const m = targetId.match(/^TreeBase-\d+/); + if (m) { + const prefix = m[0]; + elem = document.querySelector(`[id^=${prefix}]`); + } + } + if (elem) elem.focus(); + } + restoreFocus() { if (this.currentPanel) { if (this.currentPanel.lastFocused) { @@ -193,46 +207,54 @@ export class Designer extends TreeBase { */ panelKeyHandler(event) { if (event.target instanceof HTMLTextAreaElement) return; - if (event.key != "ArrowDown" && event.key != "ArrowUp") return; - if (event.shiftKey) { - // move the component - const component = Globals.designer.selectedComponent; - if (!component) return; - component.moveUpDown(event.key == "ArrowUp"); - callAfterRender(() => Globals.designer.restoreFocus()); - Globals.state.update(); - } else { - event.preventDefault(); - // get the components on this panel - // todo expand this to all components - const components = [ - ...document.querySelectorAll(".DesignerPanel.ActivePanel .settings"), - ]; - // determine which one contains the focus - const focusedComponent = document.querySelector( - '.DesignerPanel.ActivePanel .settings:has([aria-selected="true"]):not(:has(.settings [aria-selected="true"]))', - ); - if (!focusedComponent) return; - // get its index - const index = components.indexOf(focusedComponent); - // get the next index - const nextIndex = Math.min( - components.length - 1, - Math.max(0, index + (event.key == "ArrowUp" ? -1 : 1)), - ); - if (nextIndex != index) { - // focus on the first focusable in the next component - const focusable = /** @type {HTMLElement} */ ( - components[nextIndex].querySelector( - "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), " + - 'textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled]), ' + - "summary:not(:disabled)", - ) + if (event.key == "ArrowDown" || event.key == "ArrowUp") { + if (event.shiftKey) { + // move the component + const component = Globals.designer.selectedComponent; + if (!component) return; + component.moveUpDown(event.key == "ArrowUp"); + callAfterRender(() => Globals.designer.restoreFocus()); + this.currentPanel?.update(); + Globals.state.update(); + } else { + event.preventDefault(); + // get the components on this panel + // todo expand this to all components + const components = [ + ...document.querySelectorAll(".DesignerPanel.ActivePanel .settings"), + ]; + // determine which one contains the focus + const focusedComponent = document.querySelector( + '.DesignerPanel.ActivePanel .settings:has([aria-selected="true"]):not(:has(.settings [aria-selected="true"]))', ); - if (focusable) { - focusable.focus(); + if (!focusedComponent) return; + // get its index + const index = components.indexOf(focusedComponent); + // get the next index + const nextIndex = Math.min( + components.length - 1, + Math.max(0, index + (event.key == "ArrowUp" ? -1 : 1)), + ); + if (nextIndex != index) { + // focus on the first focusable in the next component + const focusable = /** @type {HTMLElement} */ ( + components[nextIndex].querySelector( + "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), " + + 'textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled]), ' + + "summary:not(:disabled)", + ) + ); + if (focusable) { + focusable.focus(); + } } } + } else if (event.key == "z") { + if (event.ctrlKey && event.shiftKey) { + this.currentPanel?.redo(); + } else if (event.ctrlKey) { + this.currentPanel?.undo(); + } } } @@ -275,7 +297,7 @@ export class Designer extends TreeBase { /** Tweak the focus behavior in the designer * I want clicking on blank space to focus the nearest focusable element - * @param {KeyboardEvent} event + * @param {PointerEvent} event */ designerClick = (event) => { // return if target is not an HTMLElement @@ -320,12 +342,14 @@ export class DesignerPanel extends TreeBase { name = new Props.String(""); label = new Props.String(""); - /** @type {Designer | null} */ - parent = null; + /** @type {Designer | undefined } */ + parent = undefined; active = false; tabName = ""; tabLabel = ""; + + settingsDetailsOpen = false; lastFocused = ""; // where to store in the db @@ -339,6 +363,8 @@ export class DesignerPanel extends TreeBase { return this.constructor.tableName; } + changeStack = new ChangeStack(); + /** * Load a panel from the database. * @@ -356,6 +382,9 @@ export class DesignerPanel extends TreeBase { const result = this.fromObject(obj); if (result instanceof expected) { result.configure(); + result.changeStack.save( + result.toObject({ omittedProps: [], includeIds: true }), + ); return result; } // I don't think this happens @@ -402,10 +431,18 @@ export class DesignerPanel extends TreeBase { configure() {} - onUpdate() { + async onUpdate() { + await this.doUpdate(true); + this.configure(); + Globals.designer.restoreFocus(); + } + + async doUpdate(save = true) { const tableName = this.staticTableName; if (tableName) { - db.write(tableName, this.toObject()); + const externalRep = this.toObject({ omittedProps: [], includeIds: true }); + await db.write(tableName, externalRep); + if (save) this.changeStack.save(externalRep); Globals.state.update(); } } @@ -413,8 +450,18 @@ export class DesignerPanel extends TreeBase { async undo() { const tableName = this.staticTableName; if (tableName) { - await db.undo(tableName); - Globals.restart(); + this.changeStack.undo(this); + await this.doUpdate(false); + Globals.designer.restoreFocus(); + } + } + + async redo() { + const tableName = this.staticTableName; + if (tableName) { + this.changeStack.redo(this); + await this.doUpdate(false); + Globals.designer.restoreFocus(); } } diff --git a/src/components/errors.js b/src/components/errors.js index 0ddf57de..d125fdea 100644 --- a/src/components/errors.js +++ b/src/components/errors.js @@ -1,5 +1,5 @@ import * as StackTrace from "stacktrace-js"; -import { html, render } from "uhtml"; +import { html } from "uhtml"; import "css/errors.css"; import { TreeBase } from "./treebase"; @@ -32,23 +32,21 @@ export class Messages extends TreeBase { function reportInternalError(msg, trace) { const result = document.createElement("div"); result.id = "ErrorReport"; - render( - result, - html`
-

Internal Error

+ function copyToClipboard() { + const html = document.getElementById("ErrorReportBody")?.innerHTML || ""; + const blob = new Blob([html], { type: "text/html" }); + const data = [new ClipboardItem({ "text/html": blob })]; + navigator.clipboard.write(data); + } + function dismiss() { + document.getElementById("ErrorReport")?.remove(); + } + result.innerHTML = `

Internal Error

Your browser has detected an internal error in OS-DPI. It was very likely caused by our program bug. We hope you will help us by sending a report of the information below. Simply click this button - and then paste into an email to @@ -57,11 +55,7 @@ function reportInternalError(msg, trace) { target="email" >gb@cs.unc.edu. -

@@ -70,12 +64,16 @@ function reportInternalError(msg, trace) {

${msg}

Stack Trace

- `, - ); + `; document.body.prepend(result); + document + .getElementById("errorCopy") + ?.addEventListener("click", copyToClipboard); + document.getElementById("errorDismiss")?.addEventListener("click", dismiss); + document.dispatchEvent(new Event("internalerror")); } /** @param {string} msg diff --git a/src/components/grid.js b/src/components/grid.js index 250ec85c..2264f635 100644 --- a/src/components/grid.js +++ b/src/components/grid.js @@ -44,8 +44,8 @@ export function imageOrVideo(src, title, onload = null) { class Grid extends TreeBase { fillItems = new Props.Boolean(false); - rows = new Props.Integer(3); - columns = new Props.Integer(3); + rows = new Props.Integer(3, { min: 1 }); + columns = new Props.Integer(3, { min: 1 }); scale = new Props.Float(1); name = new Props.String("grid"); background = new Props.Color("white"); @@ -147,8 +147,8 @@ class Grid extends TreeBase { /** @type {Partial} */ const style = { backgroundColor: this.background.value }; const { data } = Globals; - let rows = this.rows.value; - let columns = this.columns.value; + let rows = Math.max(1, this.rows.value); + let columns = Math.max(1, this.columns.value); let fillItems = this.fillItems.value; /** @type {Rows} */ let items = data.getMatchingRows(this.children); @@ -223,9 +223,17 @@ class Grid extends TreeBase { } } + // empty result provokes a crash from uhtmlV4 + if (!result.length) { + rows = columns = 1; + result.push(this.emptyCell()); + } + style.gridTemplate = `repeat(${rows}, calc(100% / ${rows})) / repeat(${columns}, 1fr)`; - return this.component({ style }, html`${result}`); + const body = html`
${result}
`; + + return this.component({}, body); } settingsDetails() { diff --git a/src/components/headmouse/tracky-mouse/tracky-mouse.js b/src/components/headmouse/tracky-mouse/tracky-mouse.js index ae3fc724..6b0427f5 100644 --- a/src/components/headmouse/tracky-mouse/tracky-mouse.js +++ b/src/components/headmouse/tracky-mouse/tracky-mouse.js @@ -1,3 +1,4 @@ +// @ts-nocheck export const TrackyMouse = { dependenciesRoot: "./tracky-mouse", }; diff --git a/src/components/layout.js b/src/components/layout.js index 5ccba2e9..e020a5ff 100644 --- a/src/components/layout.js +++ b/src/components/layout.js @@ -2,7 +2,6 @@ import { html } from "uhtml"; import { TreeBase } from "./treebase"; import { DesignerPanel } from "./designer"; import "css/layout.css"; -import db from "app/db"; import Globals from "app/globals"; import { TabPanel } from "./tabcontrol"; import { callAfterRender } from "app/render"; @@ -96,23 +95,15 @@ export class Layout extends DesignerPanel { return obj; } obj = oldToNew(obj); - // upgrade from the old format - return { - className: "Layout", - props: { name: "Layout" }, - children: [obj], - }; - } - - toObject() { - return this.children[0].toObject(); - } - - /** Update the state - */ - onUpdate() { - db.write("layout", this.children[0].toObject()); - Globals.state.update(); + // make sure it begins with Layout + if (obj.className != "Layout" && obj.className == "Page") { + obj = { + className: "Layout", + props: { name: "Layout" }, + children: [obj], + }; + } + return obj; } /** Allow highlighting the current component in the UI @@ -123,7 +114,7 @@ export class Layout extends DesignerPanel { element.removeAttribute("highlight"); } // find the selection in the panel - let selected = document.querySelector("#UI [aria-selected]"); + let selected = document.querySelector("#designer .layout [aria-selected]"); if (!selected) return; selected = selected.closest("[id]"); if (!selected) return; diff --git a/src/components/menu.js b/src/components/menu.js index 342d9305..e8cb836e 100644 --- a/src/components/menu.js +++ b/src/components/menu.js @@ -13,20 +13,29 @@ export class MenuItem { * @param {Object} obj - argument object * @param {string} obj.label * @param {Function | null} [ obj.callback ] + * @param {boolean} [obj.disable] * @param {any[]} [ obj.args ] * @param {string} [ obj.title ] * @param {string} [ obj.divider ] */ - constructor({ label, callback = null, args = [], title = "", divider = "" }) { + constructor({ + label, + callback = null, + args = [], + title = "", + divider = "", + disable = false, + }) { this.label = label; this.callback = callback; + this.disable = !!disable; this.args = args; this.title = title; this.divider = divider; } apply() { - if (this.callback) this.callback(...this.args); + if (this.callback && !this.disable) this.callback(...this.args); } } @@ -94,7 +103,7 @@ export class Menu { return html`