`;
}
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
-
@@ -70,12 +64,16 @@ function reportInternalError(msg, trace) {
${msg}
Stack Trace
- ${trace.map((s) => html`
${s}
`)}
+ ${trace.map((s) => `
${s}
`).join("")}
- `,
- );
+ `;
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`
{
if (item.callback) {
diff --git a/src/components/modal-dialog.js b/src/components/modal-dialog.js
index af28a69d..b702204f 100644
--- a/src/components/modal-dialog.js
+++ b/src/components/modal-dialog.js
@@ -1,24 +1,29 @@
import { html } from "uhtml";
import { TreeBase } from "./treebase";
+import { StackContainer } from "./stack";
import * as Props from "./props";
import "css/modal-dialog.css";
import Globals from "app/globals";
-export class ModalDialog extends TreeBase {
+export class ModalDialog extends StackContainer {
stateName = new Props.String("$modalOpen");
open = new Props.Boolean(false);
- allowedChildren = ["Stack"];
+ /** @param {string[]} classes */
+ CSSClasses(...classes) {
+ return super.CSSClasses("open", ...classes);
+ }
template() {
const state = Globals.state;
const open =
!!state.get(this.stateName.value) || this.open.value ? "open" : "";
- return this.component(
- { classes: [open] },
- html`
`;
})}
diff --git a/src/components/monkeyTest.js b/src/components/monkeyTest.js
new file mode 100644
index 00000000..ce41bdde
--- /dev/null
+++ b/src/components/monkeyTest.js
@@ -0,0 +1,302 @@
+/** Monkey test to find bugs */
+
+import { TreeBase } from "components/treebase";
+import { getEditMenuItems, getPanelMenuItems } from "components/toolbar";
+import Globals from "app/globals";
+import { MenuItem } from "components/menu";
+import * as Props from "./props";
+
+const panelNames = ["Layout", "Actions", "Cues", "Patterns", "Methods"];
+
+/* Don't trigger these menu entries */
+const MenuItemBlacklist = [
+ "Audio",
+ "Head Mouse",
+ "Logger",
+ "Speech",
+ "Socket Handler",
+ "Copy",
+ "Cut",
+ "Paste",
+ "Paste Into",
+];
+
+/** A simple seeded random number generator */
+class SeededRandom {
+ /** @param {number} seed */
+ constructor(seed) {
+ /** @type {number} */
+ this.seed = seed;
+ }
+
+ // splittwist32
+ random() {
+ this.seed |= 0;
+ this.seed = (this.seed + 0x9e3779b9) | 0;
+ var t = this.seed ^ (this.seed >>> 16);
+ t = Math.imul(t, 0x21f0aaad);
+ t = t ^ (t >>> 15);
+ t = Math.imul(t, 0x735a2d97);
+ return ((t = t ^ (t >>> 15)) >>> 0) / 4294967296;
+ }
+
+ string() {
+ const n = 4 + Math.floor(this.random() * 4);
+ return this.random().toString(36).slice(2, n);
+ }
+
+ integer() {
+ return Math.floor(this.random() * 10).toString();
+ }
+
+ float() {
+ return (this.random() * 10).toString();
+ }
+
+ /** Choose one from an array
+ * @template T
+ * @param {T[]} items
+ * @returns {T}
+ */
+ choose(items) {
+ return items[Math.floor(this.random() * items.length)];
+ }
+}
+
+const seed = 0; // set non-zero for repeatability
+const actualSeed = seed || Date.now() | 0;
+const random = new SeededRandom(actualSeed);
+
+let updates = 0; // count the number of changes to the interface
+
+/** Implement the test
+ */
+function* monkeyTest() {
+ console.log("Random seed:", random.seed.toString(16));
+ let steps = 100;
+
+ for (let step = 0; step < steps; step++) {
+ // choose a panel
+ const panelName = random.choose(panelNames);
+ Globals.designer.switchTab(panelName);
+ yield true;
+
+ // get the panel object
+ const panel = Globals.designer.currentPanel;
+
+ if (panel) {
+ const components = listChildren(panel);
+ const component = random.choose(components);
+ if (component) {
+ // focus on it
+ panel.lastFocused = component.id;
+ Globals.designer.restoreFocus();
+ } else {
+ panel.lastFocused = "";
+ }
+
+ const { child } = getPanelMenuItems("add");
+
+ // get menu items
+ let menuItems = [
+ ...child,
+ ...getEditMenuItems(),
+ ...getPropertyEdits(component),
+ ];
+
+ menuItems = menuItems.filter((item) => {
+ return MenuItemBlacklist.indexOf(item.label) < 0;
+ });
+ menuItems = menuItems.filter((item) => item.callback && !item.disable);
+
+ console.assert(
+ !menuItems.find((item) => item.label == "Page"),
+ "Should not add Page",
+ );
+
+ // choose one
+ const menuItem = random.choose(menuItems);
+ if (menuItem && menuItem.callback) {
+ // console.log(menuItem.label, components.indexOf(component), component);
+ menuItem.callback();
+ updates++;
+ yield true;
+ }
+ }
+ // check for overflow
+ const UI = document.getElementById("UI");
+ let overflow = false; // report only once per occurance
+ if (
+ UI &&
+ (UI.scrollWidth > UI.clientWidth || UI.scrollHeight > UI.clientHeight)
+ ) {
+ if (!overflow) {
+ console.error(
+ `UI overflow on step ${step} scroll w=${UI.scrollWidth} h=${UI.scrollHeight} client w=${UI.clientWidth} h=${UI.clientHeight}`,
+ );
+ overflow = true;
+ }
+ } else {
+ overflow = false;
+ }
+ }
+
+ // now undo all those changes
+ let undos = 0;
+ for (const panel of Globals.designer.children) {
+ const panelName = panel.name.value;
+ if (panelNames.indexOf(panelName) >= 0) {
+ Globals.designer.switchTab(panelName);
+ yield true;
+
+ while (panel.changeStack.canUndo) {
+ undos++;
+ panel.undo();
+ yield true;
+ }
+ }
+ }
+ console.log(
+ `Test complete: ${steps} steps ${updates} updates ${undos} undos`,
+ );
+
+ yield false;
+}
+
+/** Run the monkey test
+ */
+export function monkey() {
+ // quit in case of error
+ document.addEventListener("internalerror", () => test.return());
+ // start the generator
+ const test = monkeyTest();
+ // allow stopping the test with a key
+ const stopHandler = ({ key, ctrlKey }) =>
+ key == "q" && ctrlKey && test.return();
+ document.addEventListener("keyup", stopHandler);
+ // delay if the render isnt complete but don't require it
+ let wait = 0;
+ document.addEventListener("rendercomplete", () => (wait = 0));
+ const timer = setInterval(() => {
+ if (wait <= 0) {
+ wait = 5;
+ if (!test.next().value) {
+ clearTimeout(timer);
+ document.removeEventListener("keyup", stopHandler);
+ }
+ } else {
+ wait--;
+ }
+ }, 20);
+}
+
+if (location.host.match(/^localhost.*$|^bs-local.*$/)) {
+ document.addEventListener(
+ "keyup",
+ ({ key, ctrlKey }) => key == "m" && ctrlKey && monkey(),
+ );
+}
+
+/**
+ * Fakeup menu items that diddle the property values
+ *
+ * @param {TreeBase} component
+ * @returns {MenuItem[]}
+ */
+function getPropertyEdits(component) {
+ if (!component) return [];
+ const props = component.props;
+ /** @type {MenuItem[]} */
+ const items = [];
+
+ /** @type {function|undefined} */
+ let callback = undefined;
+ for (const name in props) {
+ const prop = props[name];
+ if (prop instanceof Props.String) {
+ callback = () => typeInto(prop, random.string());
+ } else if (prop instanceof Props.Integer) {
+ callback = () => typeInto(prop, random.integer());
+ } else if (prop instanceof Props.Float) {
+ callback = () => typeInto(prop, random.float());
+ } else if (prop instanceof Props.Select) {
+ callback = () => {
+ const element = document.getElementById(prop.id);
+ if (element instanceof HTMLSelectElement) {
+ const options = element.options;
+ const option = random.choose([...options]);
+ if (option instanceof HTMLOptionElement) {
+ element.value = option.value;
+ element.dispatchEvent(new Event("change"));
+ }
+ }
+ };
+ } else if (prop instanceof Props.Color) {
+ callback = () => {
+ const element = document.getElementById(prop.id);
+ if (element instanceof HTMLInputElement) {
+ const list = document.getElementById("ColorNames");
+ if (list instanceof HTMLDataListElement) {
+ const color = random.choose([...list.options]);
+ if (color instanceof HTMLOptionElement) {
+ element.value = color.value;
+ element.dispatchEvent(new Event("change"));
+ }
+ }
+ }
+ };
+ } else if (
+ prop instanceof Props.Boolean ||
+ prop instanceof Props.OneOfGroup
+ ) {
+ callback = () => {
+ const element = document.getElementById(prop.id);
+ if (element instanceof HTMLInputElement && element.type == "checkbox") {
+ element.checked = !element.checked;
+ element.dispatchEvent(new Event("change"));
+ }
+ };
+ } else if (prop instanceof Props.Conditional) {
+ callback = () => typeInto(prop, random.choose(["true", "false"]));
+ } else if (prop instanceof Props.Expression) {
+ callback = () => typeInto(prop, random.choose(["1+1", "2*0"]));
+ } else {
+ continue;
+ }
+ const item = new MenuItem({
+ label: `Change ${component.className}.${prop.label}`,
+ callback,
+ disable: !component.allowDelete,
+ });
+ items.push(item);
+ }
+ return items;
+}
+
+/**
+ * @param {Props.Prop} prop
+ * @param {string} value
+ */
+function typeInto(prop, value) {
+ const input = document.getElementById(prop.id);
+ if (input instanceof HTMLInputElement) {
+ input.focus();
+ input.value = value;
+ input.dispatchEvent(new Event("change"));
+ }
+}
+
+/** @param {TreeBase} component */
+function listChildren(component) {
+ /** @type {TreeBase[]} */
+ const result = [];
+ /** @param {TreeBase} node */
+ function walk(node) {
+ for (const child of node.children) {
+ result.push(child);
+ walk(child);
+ }
+ }
+ walk(component);
+ return result;
+}
diff --git a/src/components/page.js b/src/components/page.js
index fb12c6c5..6fad824c 100644
--- a/src/components/page.js
+++ b/src/components/page.js
@@ -1,6 +1,6 @@
-import { Stack } from "./stack";
+import { StackContainer } from "./stack";
-export class Page extends Stack {
+export class Page extends StackContainer {
// you can't delete the page
allowDelete = false;
@@ -12,8 +12,8 @@ export class Page extends Stack {
"Logger",
"ModalDialog",
"Customize",
- "HeadMouse"
+ "HeadMouse",
);
}
}
-Stack.register(Page, "Page");
+StackContainer.register(Page, "Page");
diff --git a/src/components/props.js b/src/components/props.js
index ccbf817d..e15b6290 100644
--- a/src/components/props.js
+++ b/src/components/props.js
@@ -235,7 +235,6 @@ export class Prop {
get value() {
if (this.compiled) {
if (!this.formula) {
- console.log(this.options);
this._value = this.options.valueWhenEmpty ?? "";
} else {
const v = this.compiled();
@@ -255,6 +254,8 @@ export class Prop {
const v = this.compiled(context);
this._value = this.cast(v);
}
+ } else if (this.isFormulaByDefault) {
+ this._value = this.options.valueWhenEmpty ?? "";
}
return this._value;
}
@@ -364,8 +365,9 @@ export class TypeSelect extends Select {
/* Magic happens here! The replace method on a TreeBaseSwitchable replaces the
* node with a new one to allow type switching in place
* */
- if (this.container instanceof TreeBaseSwitchable)
+ if (this.container instanceof TreeBaseSwitchable && this._value) {
this.container.replace(this._value);
+ }
}
}
@@ -463,10 +465,10 @@ export class Integer extends Prop {
function validate(value) {
if (!/^[0-9]+$/.test(value)) return "Please enter a whole number";
if (typeof options.min === "number" && parseInt(value) < options.min) {
- return `Please enter a whole number at least ${this.options.min}`;
+ return `Please enter a whole number at least ${options.min}`;
}
if (typeof options.max === "number" && parseInt(value) > options.max) {
- return `Please enter a whole number at most ${this.options.max}`;
+ return `Please enter a whole number at most ${options.max}`;
}
return "";
}
@@ -790,7 +792,7 @@ export class Code extends Prop {
return this.labeled(
html`
-
+
`;
}
diff --git a/src/components/treebase.js b/src/components/treebase.js
index c6c37a20..d3aa3af7 100644
--- a/src/components/treebase.js
+++ b/src/components/treebase.js
@@ -1,17 +1,15 @@
import { html } from "uhtml";
import * as Props from "./props";
import "css/treebase.css";
-import WeakValue from "weak-value";
import { styleString } from "./style";
-import { session } from "./persist";
import { errorHandler } from "./errors";
import { friendlyName } from "./names";
export class TreeBase {
/** @type {TreeBase[]} */
children = [];
- /** @type {TreeBase | null} */
- parent = null;
+ /** @type {TreeBase | undefined } */
+ parent = undefined;
/** @type {string[]} */
allowedChildren = [];
allowDelete = true;
@@ -20,23 +18,31 @@ export class TreeBase {
static treeBaseCounter = 0;
id = `TreeBase-${TreeBase.treeBaseCounter++}`;
- // values here are stored in sessionStorage
- persisted = session(this.id, {
- settingsDetailsOpen: false,
- });
+ settingsDetailsOpen = false;
// map from id to the component
- static idMap = new WeakValue();
+ /** @type {Map} */
+ static idMap = new Map();
/** @param {string} id
- * @returns {TreeBase | null} */
+ * @returns {TreeBase | undefined } */
static componentFromId(id) {
// strip off any added bits of the id
const match = id.match(/TreeBase-\d+/);
if (match) {
return this.idMap.get(match[0]);
}
- return null;
+ return undefined;
+ }
+
+ /** Remove this component and its children from the idMap
+ * @param {TreeBase} component
+ */
+ static removeFromIdMap(component) {
+ this.idMap.delete(component.id);
+ for (const child of component.children) {
+ this.removeFromIdMap(child);
+ }
}
designer = {};
@@ -84,6 +90,7 @@ export class TreeBase {
* Prepare a TreeBase tree for external storage by converting to simple objects and arrays
* @param {Object} [options]
* @param {string[]} options.omittedProps - class names of props to omit
+ * @param {boolean} [options.includeIds] - true to include the ids
* @returns {Object}
* */
toObject(options = { omittedProps: [] }) {
@@ -102,6 +109,9 @@ export class TreeBase {
props,
children,
};
+ if (options.includeIds) {
+ result.id = this.id;
+ }
return result;
}
@@ -129,9 +139,10 @@ export class TreeBase {
* @param {string|(new()=>TB)} constructorOrName
* @param {TreeBase | null} parent
* @param {Object} props
+ * @param {string} [id] - set the newly created id
* @returns {TB}
* */
- static create(constructorOrName, parent = null, props = {}) {
+ static create(constructorOrName, parent = null, props = {}, id = "") {
const constructor =
typeof constructorOrName == "string"
? TreeBase.nameToClass.get(constructorOrName)
@@ -139,6 +150,10 @@ export class TreeBase {
/** @type {TB} */
const result = new constructor();
+ if (id) {
+ result.id = id;
+ }
+
// initialize the props
for (const [name, prop] of Object.entries(result.props)) {
prop.initialize(name, props[name], result);
@@ -160,9 +175,11 @@ export class TreeBase {
* Instantiate a TreeBase tree from its external representation
* @param {Object} obj
* @param {TreeBase | null} parent
+ * @param {Object} [options]
+ * @param {boolean} [options.useId]
* @returns {TreeBase} - should be {this} but that isn't supported for some reason
* */
- static fromObject(obj, parent = null) {
+ static fromObject(obj, parent = null, options = { useId: false }) {
// Get the constructor from the class map
if (!obj) console.trace("fromObject", obj);
const className = obj.className;
@@ -173,7 +190,12 @@ export class TreeBase {
}
// Create the object and link it to its parent
- const result = this.create(constructor, parent, obj.props);
+ const result = this.create(
+ constructor,
+ parent,
+ obj.props,
+ options.useId ? obj.id || "" : "",
+ );
// Link in the children
for (const childObj of obj.children) {
@@ -181,7 +203,7 @@ export class TreeBase {
childObj.parent = result;
result.children.push(childObj);
} else {
- TreeBase.fromObject(childObj, result);
+ TreeBase.fromObject(childObj, result, options);
}
}
@@ -202,7 +224,7 @@ export class TreeBase {
*/
update() {
let start = this;
- /** @type {TreeBase | null} */
+ /** @type {TreeBase | undefined } */
let p = start;
while (p) {
p.onUpdate(start);
@@ -223,15 +245,38 @@ export class TreeBase {
settings() {
const detailsId = this.id + "-details";
const settingsId = this.id + "-settings";
+ let focused = false; // suppress toggle when not focused
return html`
- (this.persisted.settingsDetailsOpen = target.open)}
+ @click=${(/** @type {PointerEvent} */ event) => {
+ if (
+ !focused &&
+ event.target instanceof HTMLElement &&
+ event.target.parentElement instanceof HTMLDetailsElement &&
+ event.target.parentElement.open &&
+ event.pointerId >= 0 // not from the keyboard
+ ) {
+ /* When we click on the summary bar of a details element that is not focused,
+ * only focus it and prevent the toggle */
+ event.preventDefault();
+ }
+ }}
+ @toggle=${(/** @type {Event} */ event) => {
+ if (event.target instanceof HTMLDetailsElement)
+ this.settingsDetailsOpen = event.target.open;
+ }}
>
- ${this.settingsSummary()}
+ {
+ /** Record if the summary was focused before we clicked */
+ focused = event.target == document.activeElement;
+ }}
+ >
+ ${this.settingsSummary()}
+
${this.settingsDetails()}
${this.settingsChildren()}
@@ -296,7 +341,7 @@ export class TreeBase {
* Wrap the body of a component
*
* @param {ComponentAttrs} attrs
- * @param {Hole} body
+ * @param {Hole|Hole[]} body
* @returns {Hole}
*/
component(attrs, body) {
@@ -305,6 +350,7 @@ export class TreeBase {
if ("classes" in attrs) {
classes = classes.concat(attrs.classes);
}
+ if (!Array.isArray(body)) body = [body];
return html`
index) {
return peers[index].id;
} else if (peers.length > 0) {
@@ -463,21 +512,36 @@ export class TreeBaseSwitchable extends TreeBase {
}
/** Replace this node with one of a compatible type
- * @param {string} className */
- replace(className) {
+ * @param {string} className
+ * @param {Object} [props] - used in undo to reset the props
+ * */
+ replace(className, props) {
if (!this.parent) return;
if (this.className == className) return;
+
+ let update = true;
// extract the values of the old props
- const props = Object.fromEntries(
- Object.entries(this)
- .filter(([_, prop]) => prop instanceof Props.Prop)
- .map(([name, prop]) => [name, prop.value]),
- );
+ if (!props) {
+ props = Object.fromEntries(
+ Object.entries(this)
+ .filter(([_, prop]) => prop instanceof Props.Prop)
+ .map(([name, prop]) => [name, prop.value]),
+ );
+ } else {
+ update = false;
+ }
const replacement = TreeBase.create(className, null, props);
replacement.init();
+ if (!(replacement instanceof TreeBaseSwitchable)) {
+ throw new Error(
+ `Invalid TreeBaseSwitchable replacement ${this.className} ${replacement.className}`,
+ );
+ }
const index = this.parent.children.indexOf(this);
this.parent.children[index] = replacement;
replacement.parent = this.parent;
- this.update();
+ if (update) {
+ this.update();
+ }
}
}
diff --git a/src/components/undo.js b/src/components/undo.js
new file mode 100644
index 00000000..9e81de0f
--- /dev/null
+++ b/src/components/undo.js
@@ -0,0 +1,181 @@
+import { TreeBase, TreeBaseSwitchable } from "./treebase";
+
+/** Implement undo/redo for the designer by comparing the current and previous trees
+ *
+ * I'm assuming only 1 change has been made since we save after every change.
+ */
+
+export class ChangeStack {
+ /** @type {ExternalRep[]} */
+ stack = [];
+
+ /* boundary between undo and redo. Points to the first cell beyond the undos */
+ top = 0;
+
+ get canUndo() {
+ return this.top > 1;
+ }
+
+ get canRedo() {
+ return this.top < this.stack.length;
+ }
+
+ /** Save a state for possible undo
+ * @param {ExternalRep} state
+ */
+ save(state) {
+ this.stack.splice(this.top);
+ this.stack.push(state);
+ this.top = this.stack.length;
+ }
+
+ /** Undo
+ * @param {TreeBase} current
+ */
+ undo(current) {
+ if (this.canUndo) {
+ this.restore(current, this.stack[this.top - 2]);
+ this.top--;
+ }
+ }
+
+ /** Redo
+ * @param {TreeBase} current
+ */
+ redo(current) {
+ if (this.canRedo) {
+ this.restore(current, this.stack[this.top]);
+ this.top++;
+ }
+ }
+
+ useDiff = false;
+
+ /**
+ * restore the state of current to previous
+ * @param {TreeBase} current
+ * @param {ExternalRep} previous
+ * @returns {boolean}
+ */
+ restore(current, previous) {
+ if (!this.useDiff) {
+ const next = TreeBase.fromObject(previous);
+ current.children = next.children;
+ current.children.forEach((child) => (child.parent = current));
+ next.update();
+ // console.log("restored", current.toObject(), previous.className, previous);
+ return true;
+ }
+ if (this.equal(current, previous)) {
+ return false;
+ }
+
+ // we get here because the are different
+ if (current.className != previous.className) {
+ // I think this happens only for the components that dynamically change their class
+ if (current instanceof TreeBaseSwitchable) {
+ // switch the class and force the props to their old values
+ console.log("change class");
+ current.replace(previous.className, previous.props);
+ } else {
+ throw new Error(
+ `Undo: non switchable class changed ${current.className} ${previous.className}`,
+ );
+ }
+ return true;
+ }
+
+ // check the props
+ const pprops = previous.props;
+ for (let propName in pprops) {
+ if (
+ pprops[propName] &&
+ propName in current &&
+ current[propName].text != pprops[propName]
+ ) {
+ current[propName].set(pprops[propName]);
+ console.log("change prop");
+ return true;
+ }
+ }
+
+ // check the children
+ const cc = current.children;
+ const pc = previous.children;
+
+ if (cc.length == pc.length - 1) {
+ // determine which one was deleted
+ // it is a merge, first difference is the one that matters
+ for (let i = 0; i < pc.length; i++) {
+ if (!this.equal(cc[i], pc[i])) {
+ // pc[i] is the one that got deleted. Create it
+ console.log(
+ "undo delete",
+ current.toObject({ omittedProps: [], includeIds: true }),
+ pc[i],
+ );
+ const deleted = TreeBase.fromObject(pc[i], current, { useId: true });
+ if (i < pc.length) {
+ // move it
+ console.log("move it", i);
+ deleted.moveTo(i);
+ }
+ return true;
+ }
+ }
+ throw new Error("Undo: delete failed");
+ } else if (cc.length == pc.length + 1) {
+ // the added one must be last
+ console.log("undo add", cc[cc.length - 1]);
+ cc[cc.length - 1].remove();
+ return true;
+ } else if (cc.length == pc.length) {
+ // check for reordering
+ let diffs = [];
+ for (let i = 0; i < cc.length; i++) {
+ if (!this.equal(cc[i], pc[i])) diffs.push(i);
+ }
+ if (diffs.length == 2) {
+ // reordered
+ console.log("swap", diffs[0], diffs[1]);
+ current.swap(diffs[0], diffs[1]);
+ return true;
+ } else if (diffs.length == 1) {
+ // changed
+ return this.restore(cc[diffs[0]], pc[diffs[0]]);
+ } else if (diffs.length == 0) {
+ return false;
+ } else {
+ throw new Error(`Undo: too many diffs ${diffs.length}`);
+ }
+ } else {
+ throw new Error(
+ `Undo: incompatible number of children ${cc.length} ${pc.length}`,
+ );
+ }
+ }
+
+ /** Compare TreeBase and ExternalRep for equality
+ * @param {TreeBase} tb - current value
+ * @param {ExternalRep} er -- previous value
+ * @returns {boolean}
+ */
+ equal(tb, er) {
+ if (!tb || !er) return false;
+
+ if (tb.id != er.id) return false;
+
+ if (tb.className != er.className) return false;
+
+ for (const prop in tb.props) {
+ if (prop in er.props) {
+ if (er.props[prop] && tb[prop].text != er.props[prop].toString())
+ return false;
+ }
+ }
+
+ if (tb.children.length != er.children.length) return false;
+
+ return tb.children.every((child, i) => this.equal(child, er.children[i]));
+ }
+}
diff --git a/src/css/actions.css b/src/css/actions.css
index 1aeab5df..6cb7590b 100644
--- a/src/css/actions.css
+++ b/src/css/actions.css
@@ -50,12 +50,10 @@ div.actions div.scroll {
background: white;
}
-#designer .actions tbody {
+#designer .actions tbody.settings {
border-top: 2px solid black;
border-left: 2px solid black;
border-right: 2px solid black;
- position: relative;
- z-index: 10;
}
.actions thead {
border-top: 0px;
diff --git a/src/css/designer.css b/src/css/designer.css
index 7c50539d..79a81085 100644
--- a/src/css/designer.css
+++ b/src/css/designer.css
@@ -87,12 +87,12 @@ details summary > * {
#designer .settings[aria-selected="true"] {
background-color: var(--surface1);
color: var(--text1);
- border: 4px dashed var(--brand);
+ outline: 4px dashed var(--brand);
}
#designer .settings:has(.settings [aria-selected="true"]) {
background-color: var(--surface2);
color: var(--text2);
- border: 0px;
+ outline: 0px;
box-shadow: none;
}
diff --git a/src/css/grid.css b/src/css/grid.css
index 0a90b504..c7e7f88b 100644
--- a/src/css/grid.css
+++ b/src/css/grid.css
@@ -1,4 +1,9 @@
.grid {
+ height: 100%;
+ width: 100%;
+}
+
+.grid > div {
display: grid;
grid-auto-rows: 1fr;
height: 100%;
@@ -11,6 +16,7 @@
border-radius: 5px;
background-color: inherit;
user-select: none;
+ border: 1px solid black;
}
.grid button div {
display: flex;
diff --git a/src/css/site.css b/src/css/site.css
index 52d3a1f6..47022998 100644
--- a/src/css/site.css
+++ b/src/css/site.css
@@ -35,7 +35,13 @@ div#UI {
position: absolute;
left: 0;
top: 0;
- width: 5em;
padding: 0.5em;
z-index: 10;
+ font-size: 0.6em;
+ background-color: white;
+ border: 1px solid var(--brand);
+}
+
+#timer:empty {
+ display: none;
}
diff --git a/src/css/tabcontrol.css b/src/css/tabcontrol.css
index 9814f9bc..415ddbba 100644
--- a/src/css/tabcontrol.css
+++ b/src/css/tabcontrol.css
@@ -8,6 +8,7 @@
}
.tabcontrol .panels {
display: flex;
+ min-height: 0;
}
.tabcontrol .buttons {
display: flex;
diff --git a/src/css/toolbar.css b/src/css/toolbar.css
index 080d8aa9..49e9ea0a 100644
--- a/src/css/toolbar.css
+++ b/src/css/toolbar.css
@@ -42,10 +42,15 @@
font-size: 2rem;
}
-dialog#OpenDialog {
+dialog {
margin: auto;
}
-dialog#OpenDialog button {
+dialog button {
margin-top: 1em;
}
+
+dialog#ImportURL input {
+ width: 100%;
+ min-width: 50ch;
+}
diff --git a/src/css/vsd.css b/src/css/vsd.css
index 5bb7510e..f73a0d59 100644
--- a/src/css/vsd.css
+++ b/src/css/vsd.css
@@ -29,8 +29,14 @@ div.vsd img,
div.vsd video {
flex: 1 1 0;
object-fit: contain;
- width: 100%;
- height: 100%;
+ max-width: 100%;
+ max-height: 100%;
+}
+
+div.vsd div.markers {
+ width: 0;
+ height: 0;
+ overflow: hidden;
}
div.vsd div.markers button:focus-within {
diff --git a/src/css/wait.css b/src/css/wait.css
index 2a477489..ed7c5bac 100644
--- a/src/css/wait.css
+++ b/src/css/wait.css
@@ -1,32 +1,30 @@
- #PleaseWait {
- position: fixed;
- width: 100vw;
- height: 100vh;
- background-color: rgb(0, 0, 0, 0.5);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 100;
- font-size: 2em;
- transition: all 0.5s ease-in;
- opacity: 1;
- }
+#PleaseWait {
+ position: fixed;
+ width: 100vw;
+ height: 100vh;
+ background-color: rgb(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+ font-size: 2em;
+ transition: all 0.5s ease-in;
+ opacity: 1;
+ top: 0;
+ left: 0;
+}
- #PleaseWait:empty {
- background-color: rgb(0, 0, 0, 0);
- opacity: 0;
- }
+#PleaseWait:empty {
+ background-color: rgb(0, 0, 0, 0);
+ opacity: 0;
+}
- #PleaseWait div {
- padding: 5em;
- border: 1px solid black;
- background-color: white;
- }
+#PleaseWait div {
+ padding: 5em;
+ border: 1px solid black;
+ background-color: white;
+}
- #PleaseWait .message {
- color: blue;
- }
-
- #PleaseWait .error {
- color: red;
- }
+#PleaseWait .message {
+ color: blue;
+}
diff --git a/src/css/welcome.css b/src/css/welcome.css
deleted file mode 100644
index 5807151c..00000000
--- a/src/css/welcome.css
+++ /dev/null
@@ -1,28 +0,0 @@
-#welcome {
- padding: 1em;
-}
-#welcome #head {
- display: flex;
- align-items: flex-start;
- justify-content: flex-start;
-}
-#welcome #head div {
- padding-left: 1em;
-}
-#welcome #logo {
- width: 100px;
- height: 100px;
- background-image: url("./icon.png");
- background-size: contain;
-}
-#welcome #head div p {
- max-width: 40em;
-}
-#timer {
- position: absolute;
- left: 0;
- top: 0;
- width: 5em;
- padding: 0.5em;
- z-index: 10;
-}
diff --git a/src/data.js b/src/data.js
index f2f9a94b..9559cc4a 100644
--- a/src/data.js
+++ b/src/data.js
@@ -50,12 +50,14 @@ export class Data {
}
updateAllFields() {
- this.allFields = this.contentRows.reduce((previous, current) => {
- for (const field of Object.keys(current)) {
- previous.add("#" + field);
- }
- return previous;
- }, new Set());
+ this.allFields = /** @type {Set} */ (
+ this.contentRows.reduce((previous, current) => {
+ for (const field of Object.keys(current)) {
+ previous.add("#" + field);
+ }
+ return previous;
+ }, new Set())
+ );
this.clearFields = {};
for (const field of this.allFields) {
this.clearFields[field.slice(1)] = null;
diff --git a/src/db.js b/src/db.js
index 68c15093..cd058ace 100644
--- a/src/db.js
+++ b/src/db.js
@@ -1,49 +1,49 @@
-import { openDB } from "idb/with-async-ittr";
+import { openDB } from "idb";
import { zipSync, strToU8, unzipSync, strFromU8 } from "fflate";
import { fileSave } from "browser-fs-access";
import Globals from "./globals";
-const N_RECORDS_SAVE = 10;
-const N_RECORDS_MAX = 20;
-
export class DB {
constructor() {
- this.dbPromise = openDB("os-dpi", 4, {
- upgrade(db, oldVersion, newVersion) {
- if (oldVersion && oldVersion < 3) {
- for (const name of ["store", "media", "saved", "url"]) {
- try {
- db.deleteObjectStore(name);
- } catch (e) {
- // ignore the error
- }
+ this.dbPromise = openDB("os-dpi", 5, {
+ async upgrade(db, oldVersion, _newVersion, transaction) {
+ let store5 = db.createObjectStore("store5", {
+ keyPath: ["name", "type"],
+ });
+ store5.createIndex("by-name", "name");
+ if (oldVersion == 4) {
+ // copy data from old store to new
+ const store4 = transaction.objectStore("store");
+ for await (const cursor of store4) {
+ const record4 = cursor.value;
+ store5.put(record4);
}
- } else if (oldVersion == 3) {
- db.deleteObjectStore("images");
- }
- if (oldVersion < 3) {
- let objectStore = db.createObjectStore("store", {
- keyPath: "id",
- autoIncrement: true,
- });
- objectStore.createIndex("by-name", "name");
- objectStore.createIndex("by-name-type", ["name", "type"]);
- }
- if (newVersion && newVersion >= 4) {
+ db.deleteObjectStore("store");
+ // add an etag index to url store
+ transaction.objectStore("url").createIndex("by-etag", "etag");
+ } else if (oldVersion < 4) {
db.createObjectStore("media");
- }
- if (oldVersion < 3) {
- // keep track of the name and ETag (if any) of designs that have been saved
let savedStore = db.createObjectStore("saved", {
keyPath: "name",
});
savedStore.createIndex("by-etag", "etag");
// track etags for urls
- db.createObjectStore("url", {
+ const urlStore = db.createObjectStore("url", {
keyPath: "url",
});
+ // add an etag index to the url store
+ urlStore.createIndex("by-etag", "etag");
}
},
+ blocked(currentVersion, blockedVersion, event) {
+ console.log("blocked", { currentVersion, blockedVersion, event });
+ },
+ blocking(_currentVersion, _blockedVersion, _event) {
+ window.location.reload();
+ },
+ terminated() {
+ console.log("terminated");
+ },
});
this.updateListeners = [];
this.designName = "";
@@ -67,12 +67,12 @@ export class DB {
async renameDesign(newName) {
const db = await this.dbPromise;
newName = await this.uniqueName(newName);
- const tx = db.transaction(["store", "media", "saved"], "readwrite");
- const index = tx.objectStore("store").index("by-name");
+ const tx = db.transaction(["store5", "media", "saved"], "readwrite");
+ const index = tx.objectStore("store5").index("by-name");
for await (const cursor of index.iterate(this.designName)) {
- const record = { ...cursor.value };
- record.name = newName;
- cursor.update(record);
+ const record = { ...cursor.value, name: newName };
+ cursor.delete();
+ tx.objectStore("store5").put(record);
}
const mst = tx.objectStore("media");
for await (const cursor of mst.iterate()) {
@@ -102,11 +102,8 @@ export class DB {
*/
async names() {
const db = await this.dbPromise;
- const index = db.transaction("store", "readonly").store.index("by-name");
- const result = [];
- for await (const cursor of index.iterate(null, "nextunique")) {
- result.push(/** @type {string} */ (cursor.key));
- }
+ const keys = await db.getAllKeysFromIndex("store5", "by-name");
+ const result = [...new Set(keys.map((key) => key.valueOf()[0]))];
return result;
}
@@ -159,21 +156,9 @@ export class DB {
*/
async read(type, defaultValue = {}) {
const db = await this.dbPromise;
- const index = db
- .transaction("store", "readonly")
- .store.index("by-name-type");
- const cursor = await index.openCursor([this.designName, type], "prev");
- if (cursor) {
- const data = cursor.value.data;
- if (
- (Array.isArray(defaultValue) && !Array.isArray(data)) ||
- typeof data != typeof defaultValue
- ) {
- return defaultValue;
- }
- return data;
- }
- return defaultValue;
+ const record = await db.get("store5", [this.designName, type]);
+ const data = record ? record.data : defaultValue;
+ return data;
}
/**
@@ -183,17 +168,7 @@ export class DB {
* @returns {Promise