tutuca 0.15.0 → 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +132 -0
- package/LICENSE +1 -1
- package/README.md +64 -170
- package/{dist → deps}/immer.js +674 -249
- package/dist/tutuca.js +2342 -4057
- package/dist/tutuca.min.js +3 -3
- package/package.json +78 -96
- package/src/component.js +66 -0
- package/src/css/classes.js +77 -0
- package/src/dev/formatters.js +393 -0
- package/src/dev/jsonml.js +35 -0
- package/src/dev/panel.js +400 -0
- package/src/dev/summary.js +239 -0
- package/src/dev/vdom-formatters.js +102 -0
- package/src/drag.js +23 -0
- package/src/embed/element.js +111 -0
- package/src/env.js +30 -0
- package/src/events.js +85 -0
- package/src/history/bar.js +198 -0
- package/src/history/index.js +149 -0
- package/src/host.js +360 -0
- package/src/lint/index.js +470 -0
- package/src/meta.js +12 -0
- package/src/optimize.js +58 -0
- package/src/paths.js +205 -0
- package/src/playground/element.js +360 -0
- package/src/playground/index.js +5 -0
- package/src/playground/sources.js +107 -0
- package/src/playground/ui/playground.js +287 -0
- package/src/schema.js +460 -0
- package/src/scope.js +92 -0
- package/src/storybook/index.js +80 -0
- package/src/storybook/runner.js +452 -0
- package/src/storybook/stories.js +208 -0
- package/src/storybook/stress.js +76 -0
- package/src/storybook/ui/common.js +37 -0
- package/src/storybook/ui/link.js +80 -0
- package/src/storybook/ui/panels.js +32 -0
- package/src/storybook/ui/story-panel.js +432 -0
- package/src/storybook/ui/storybook.js +191 -0
- package/src/storybook/ui/stress-panel.js +231 -0
- package/src/sync/automerge.js +159 -0
- package/src/sync/codec.js +206 -0
- package/src/sync/diff.js +153 -0
- package/src/sync/index.js +97 -0
- package/src/sync/migrate.js +184 -0
- package/src/sync/ops.js +160 -0
- package/src/sync/remote.js +206 -0
- package/src/tag.js +24 -0
- package/src/tasks.js +244 -0
- package/src/test/driver.js +141 -0
- package/src/test/pbt.js +589 -0
- package/src/tutuca.js +66 -0
- package/src/vdom.js +349 -0
- package/src/view.js +354 -0
- package/src/walk.js +79 -0
- package/dist/chai.js +0 -4210
- package/dist/tutuca-cli.js +0 -16315
- package/dist/tutuca-components.js +0 -2232
- package/dist/tutuca-dev.ext.js +0 -8476
- package/dist/tutuca-dev.js +0 -13220
- package/dist/tutuca-dev.min.js +0 -5
- package/dist/tutuca-extra.ext.js +0 -4261
- package/dist/tutuca-extra.js +0 -4964
- package/dist/tutuca-extra.min.js +0 -3
- package/dist/tutuca-storybook.js +0 -970
- package/dist/tutuca.ext.js +0 -4183
- package/skill/margaui/SKILL.md +0 -105
- package/skill/margaui/components/accordion.md +0 -127
- package/skill/margaui/components/alert.md +0 -174
- package/skill/margaui/components/aura.md +0 -97
- package/skill/margaui/components/avatar.md +0 -220
- package/skill/margaui/components/badge.md +0 -193
- package/skill/margaui/components/breadcrumbs.md +0 -103
- package/skill/margaui/components/button.md +0 -322
- package/skill/margaui/components/calendar.md +0 -67
- package/skill/margaui/components/card.md +0 -373
- package/skill/margaui/components/carousel.md +0 -387
- package/skill/margaui/components/chat.md +0 -171
- package/skill/margaui/components/checkbox.md +0 -101
- package/skill/margaui/components/collapse.md +0 -172
- package/skill/margaui/components/countdown.md +0 -165
- package/skill/margaui/components/diff.md +0 -53
- package/skill/margaui/components/divider.md +0 -107
- package/skill/margaui/components/dock.md +0 -173
- package/skill/margaui/components/drawer.md +0 -184
- package/skill/margaui/components/dropdown.md +0 -388
- package/skill/margaui/components/fab.md +0 -346
- package/skill/margaui/components/fieldset.md +0 -88
- package/skill/margaui/components/file-input.md +0 -84
- package/skill/margaui/components/filter.md +0 -52
- package/skill/margaui/components/footer.md +0 -583
- package/skill/margaui/components/hero.md +0 -135
- package/skill/margaui/components/hover-3d.md +0 -129
- package/skill/margaui/components/hover-gallery.md +0 -49
- package/skill/margaui/components/indicator.md +0 -265
- package/skill/margaui/components/input.md +0 -389
- package/skill/margaui/components/join.md +0 -100
- package/skill/margaui/components/kbd.md +0 -127
- package/skill/margaui/components/label.md +0 -102
- package/skill/margaui/components/link.md +0 -96
- package/skill/margaui/components/list.md +0 -182
- package/skill/margaui/components/loading.md +0 -105
- package/skill/margaui/components/mask.md +0 -168
- package/skill/margaui/components/megamenu.md +0 -131
- package/skill/margaui/components/menu.md +0 -887
- package/skill/margaui/components/mockup-browser.md +0 -39
- package/skill/margaui/components/mockup-code.md +0 -81
- package/skill/margaui/components/mockup-phone.md +0 -39
- package/skill/margaui/components/mockup-window.md +0 -33
- package/skill/margaui/components/modal.md +0 -196
- package/skill/margaui/components/navbar.md +0 -282
- package/skill/margaui/components/otp.md +0 -171
- package/skill/margaui/components/pagination.md +0 -122
- package/skill/margaui/components/progress.md +0 -135
- package/skill/margaui/components/radial-progress.md +0 -67
- package/skill/margaui/components/radio.md +0 -133
- package/skill/margaui/components/range.md +0 -134
- package/skill/margaui/components/rating.md +0 -170
- package/skill/margaui/components/select.md +0 -225
- package/skill/margaui/components/skeleton.md +0 -64
- package/skill/margaui/components/stack.md +0 -142
- package/skill/margaui/components/stat.md +0 -254
- package/skill/margaui/components/status.md +0 -73
- package/skill/margaui/components/steps.md +0 -138
- package/skill/margaui/components/swap.md +0 -152
- package/skill/margaui/components/tab.md +0 -248
- package/skill/margaui/components/table.md +0 -1018
- package/skill/margaui/components/text-rotate.md +0 -91
- package/skill/margaui/components/textarea.md +0 -85
- package/skill/margaui/components/theme-controller.md +0 -266
- package/skill/margaui/components/timeline.md +0 -1356
- package/skill/margaui/components/toast.md +0 -165
- package/skill/margaui/components/toggle.md +0 -135
- package/skill/margaui/components/tooltip.md +0 -181
- package/skill/margaui/components/validator.md +0 -163
- package/skill/tutuca/SKILL.md +0 -56
- package/skill/tutuca/advanced.md +0 -292
- package/skill/tutuca/cli.md +0 -239
- package/skill/tutuca/component-design.md +0 -168
- package/skill/tutuca/core.md +0 -935
- package/skill/tutuca/iteration.md +0 -207
- package/skill/tutuca/macros.md +0 -86
- package/skill/tutuca/margaui.md +0 -175
- package/skill/tutuca/messages-and-intents.md +0 -414
- package/skill/tutuca/patterns/README.md +0 -48
- package/skill/tutuca/patterns/add-a-story.md +0 -26
- package/skill/tutuca/patterns/bind-text-and-attributes.md +0 -30
- package/skill/tutuca/patterns/conditional-attribute-value.md +0 -29
- package/skill/tutuca/patterns/coordinate-components.md +0 -54
- package/skill/tutuca/patterns/edit-through-a-dynamic-target.md +0 -32
- package/skill/tutuca/patterns/enrich-each-item.md +0 -25
- package/skill/tutuca/patterns/file-input.md +0 -39
- package/skill/tutuca/patterns/filter-a-list.md +0 -25
- package/skill/tutuca/patterns/filter-and-paginate.md +0 -60
- package/skill/tutuca/patterns/handle-events.md +0 -45
- package/skill/tutuca/patterns/iterate-a-list.md +0 -18
- package/skill/tutuca/patterns/paginate-a-list.md +0 -29
- package/skill/tutuca/patterns/render-a-child-component.md +0 -21
- package/skill/tutuca/patterns/reuse-markup-with-macros.md +0 -36
- package/skill/tutuca/patterns/share-state-across-the-tree.md +0 -53
- package/skill/tutuca/patterns/show-or-hide-content.md +0 -23
- package/skill/tutuca/patterns/switch-between-views.md +0 -30
- package/skill/tutuca/patterns/tabbed-interface.md +0 -43
- package/skill/tutuca/semantics.md +0 -219
- package/skill/tutuca/storybook.md +0 -270
- package/skill/tutuca/styles.md +0 -48
- package/skill/tutuca/testing.md +0 -345
- package/skill/tutuca-source/SKILL.md +0 -33
- package/skill/tutuca-source/tutuca.ext.js +0 -4183
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// DevTools custom formatters for vdom trees (Chrome/Firefox), shown like the Elements panel.
|
|
2
|
+
import { HTML_NS, VBase, VComment, VFragment, VNode, VText } from "../vdom.js";
|
|
3
|
+
import { install, makeFormatter, objectRef as ref, span, truncate } from "./jsonml.js";
|
|
4
|
+
|
|
5
|
+
const S = {
|
|
6
|
+
tag: "color:#9333ea",
|
|
7
|
+
attr: "color:#d97706",
|
|
8
|
+
str: "color:#16a34a",
|
|
9
|
+
muted: "opacity:0.6",
|
|
10
|
+
removed: "opacity:0.6;text-decoration:line-through",
|
|
11
|
+
};
|
|
12
|
+
const MAX_STR = 40;
|
|
13
|
+
const MAX_INLINE_ATTRS = 4;
|
|
14
|
+
|
|
15
|
+
const quoted = (s) => span(S.str, JSON.stringify(truncate(s, MAX_STR)));
|
|
16
|
+
|
|
17
|
+
// every stops at the first non vnode, so big arrays of other things bail out on item 0.
|
|
18
|
+
const isVdomArray = (v) => Array.isArray(v) && v.length > 0 && v.every((c) => c instanceof VBase);
|
|
19
|
+
|
|
20
|
+
const tagName = (node) => {
|
|
21
|
+
const implicit = node.namespace === null || node.namespace === HTML_NS;
|
|
22
|
+
if (implicit || node.tag === "svg" || node.tag === "math") return node.tag;
|
|
23
|
+
return `${node.namespace.split("/").at(-1).toLowerCase()}:${node.tag}`; // e.g. svg:circle
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const attrParts = (name, value) => {
|
|
27
|
+
if (value === true) return [" ", span(S.attr, name)];
|
|
28
|
+
if (value === false || value == null) return [" ", span(S.removed, `${name}=${value}`)];
|
|
29
|
+
if (typeof value === "function") return [" ", span(S.muted, `${name}=ƒ`)];
|
|
30
|
+
if (typeof value === "object") return [" ", span(S.attr, name), "=", ref(value)];
|
|
31
|
+
return [" ", span(S.attr, name), "=", quoted(String(value))];
|
|
32
|
+
};
|
|
33
|
+
const keyParts = (key) => (key === undefined ? [] : [" ", span(S.muted, `key="${key}"`)]);
|
|
34
|
+
|
|
35
|
+
const openTag = (node) => {
|
|
36
|
+
const names = Object.keys(node.attrs);
|
|
37
|
+
const parts = [span(S.tag, `<${tagName(node)}`), ...keyParts(node.key)];
|
|
38
|
+
for (const name of names.slice(0, MAX_INLINE_ATTRS)) {
|
|
39
|
+
parts.push(...attrParts(name, node.attrs[name]));
|
|
40
|
+
}
|
|
41
|
+
if (names.length > MAX_INLINE_ATTRS) {
|
|
42
|
+
parts.push(" ", span(S.muted, `…+${names.length - MAX_INLINE_ATTRS}`));
|
|
43
|
+
}
|
|
44
|
+
parts.push(span(S.tag, ">"));
|
|
45
|
+
return parts;
|
|
46
|
+
};
|
|
47
|
+
const closeTag = (node) => span(S.tag, `</${tagName(node)}>`);
|
|
48
|
+
// A lone short text child renders inline, like the Elements panel does.
|
|
49
|
+
const inlineText = (node) => {
|
|
50
|
+
const [child] = node.childs;
|
|
51
|
+
return node.childs.length === 1 && child instanceof VText && child.text.length <= MAX_STR
|
|
52
|
+
? child
|
|
53
|
+
: null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const vdomFormatter = (comment) => {
|
|
57
|
+
const commentParts = (node) => comment?.(node.text) ?? [span(S.muted, `<!--${node.text}-->`)];
|
|
58
|
+
const describe = (v) => {
|
|
59
|
+
if (v instanceof VNode) {
|
|
60
|
+
const text = inlineText(v);
|
|
61
|
+
if (v.childs.length === 0 || text !== null) {
|
|
62
|
+
const inner = text === null ? [] : [text.text];
|
|
63
|
+
return { header: [...openTag(v), ...inner, closeTag(v)], childs: [] };
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
header: [...openTag(v), span(S.muted, "…"), closeTag(v)],
|
|
67
|
+
childs: v.childs,
|
|
68
|
+
close: closeTag(v),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (v instanceof VText) return { header: [quoted(v.text)], childs: [] };
|
|
72
|
+
if (v instanceof VComment) return { header: commentParts(v), childs: [] };
|
|
73
|
+
if (v instanceof VFragment) {
|
|
74
|
+
return {
|
|
75
|
+
header: [span(S.tag, "<>"), ...keyParts(v.key), span(S.muted, ` (${v.childs.length})`)],
|
|
76
|
+
childs: v.childs,
|
|
77
|
+
close: span(S.tag, "</>"),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (isVdomArray(v)) {
|
|
81
|
+
const noun = v.length === 1 ? "vnode" : "vnodes";
|
|
82
|
+
return { header: [span(S.muted, `[${v.length} ${noun}]`)], childs: v };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
};
|
|
86
|
+
return makeFormatter({
|
|
87
|
+
header: (v) => describe(v)?.header ?? null,
|
|
88
|
+
rows(v) {
|
|
89
|
+
const d = describe(v);
|
|
90
|
+
if (d === null || d.childs.length === 0) return [];
|
|
91
|
+
const rows = d.childs.map((c) => ["li", {}, ref(c)]);
|
|
92
|
+
if (d.close !== undefined) rows.push(["li", {}, d.close]);
|
|
93
|
+
return rows;
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const installed = new WeakMap(); // target → formatter, so installing twice is a no-op
|
|
99
|
+
export const installVdomFormatters = (target = globalThis, { comment } = {}) => {
|
|
100
|
+
if (!installed.has(target)) installed.set(target, install(target, vdomFormatter(comment)));
|
|
101
|
+
return installed.get(target);
|
|
102
|
+
};
|
package/src/drag.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Drag and drop: the drag the host keeps from dragstart to dragend, read by handlers as ctx.drag.
|
|
2
|
+
|
|
3
|
+
// The events over a drop target. They reach the nearest enclosing component that handles them,
|
|
4
|
+
// so a drop on a child's elements gets to the parent's on:drop.
|
|
5
|
+
export const DROP_EVENTS = new Set(["dragenter", "dragover", "dragleave", "drop"]);
|
|
6
|
+
// What the host listens to once a component handles drop.
|
|
7
|
+
export const DRAG_EVENTS = ["dragstart", "dragend", ...DROP_EVENTS];
|
|
8
|
+
|
|
9
|
+
// The element being dragged: where it was rendered, its data-dragtype and the value there
|
|
10
|
+
// when the drag started.
|
|
11
|
+
export class Drag {
|
|
12
|
+
constructor(path, type, node, value) {
|
|
13
|
+
this.path = path;
|
|
14
|
+
this.type = type;
|
|
15
|
+
this.node = node;
|
|
16
|
+
this.value = value;
|
|
17
|
+
}
|
|
18
|
+
// The key of the child of receiver the dragged element is in, as sourceKey is for a
|
|
19
|
+
// message; null when it was dragged from outside receiver.
|
|
20
|
+
keyIn(receiver) {
|
|
21
|
+
return this.path.sourceKeyIn(receiver);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// An isolated tutuca app inside another: <tutuca-app app-key="..."> asks its ancestors for
|
|
2
|
+
// an app to mount in its shadow root. The outer render never morphs shadow content and
|
|
3
|
+
// events leaving it are retargeted to the element, so the outer Host never sees inside.
|
|
4
|
+
//
|
|
5
|
+
// Protocol, both as bubbling CustomEvents on the element:
|
|
6
|
+
// - REQUEST, on connect and when app-key changes. Its detail is { key, element, rootNode,
|
|
7
|
+
// document, adoptCss(text) → <style>, adoptLink(href), emit(type, data), mount(controller) }; a
|
|
8
|
+
// listener mounts an app on rootNode and passes mount a controller with dispose().
|
|
9
|
+
// - REPORT, sent by emit, with detail { key, type, data }. An outer view can handle it
|
|
10
|
+
// like any event: h("tutuca-app", { "app-key": ..., "on:tutucareport": send(...) }).
|
|
11
|
+
// Reports go out in a microtask, in order: a request answered during the outer render,
|
|
12
|
+
// or a call from an outer task, never reenters the outer Host.
|
|
13
|
+
// The outer side calls the app through element.controller or its own registry by key.
|
|
14
|
+
import { Host } from "../tutuca.js";
|
|
15
|
+
|
|
16
|
+
export const REQUEST = "tutucarequest";
|
|
17
|
+
export const REPORT = "tutucareport";
|
|
18
|
+
|
|
19
|
+
// Defines the element in window once; returns its class.
|
|
20
|
+
export const defineTutucaApp = (window, tagName = "tutuca-app") => {
|
|
21
|
+
const existing = window.customElements.get(tagName);
|
|
22
|
+
if (existing !== undefined) return existing;
|
|
23
|
+
class TutucaApp extends window.HTMLElement {
|
|
24
|
+
static observedAttributes = ["app-key"];
|
|
25
|
+
controller = null;
|
|
26
|
+
mountedKey = null;
|
|
27
|
+
current = null; // The detail of the live request; emits from older ones are dropped.
|
|
28
|
+
get key() {
|
|
29
|
+
return this.getAttribute("app-key");
|
|
30
|
+
}
|
|
31
|
+
// A keyed move (insertBefore) disconnects and reconnects; keep the app through it.
|
|
32
|
+
connectedCallback() {
|
|
33
|
+
if (this.current === null || this.mountedKey !== this.key) this.request();
|
|
34
|
+
}
|
|
35
|
+
disconnectedCallback() {
|
|
36
|
+
queueMicrotask(() => {
|
|
37
|
+
if (!this.isConnected) this.dispose();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
attributeChangedCallback() {
|
|
41
|
+
if (this.isConnected) this.request();
|
|
42
|
+
}
|
|
43
|
+
request() {
|
|
44
|
+
this.dispose();
|
|
45
|
+
const doc = this.ownerDocument;
|
|
46
|
+
const shadow = this.shadowRoot ?? this.attachShadow({ mode: "open" });
|
|
47
|
+
const rootNode = doc.createElement("div");
|
|
48
|
+
shadow.replaceChildren(rootNode);
|
|
49
|
+
const key = this.key;
|
|
50
|
+
let controller = null;
|
|
51
|
+
const detail = {
|
|
52
|
+
key,
|
|
53
|
+
element: this,
|
|
54
|
+
rootNode,
|
|
55
|
+
document: doc,
|
|
56
|
+
adoptCss: (text) => {
|
|
57
|
+
const style = doc.createElement("style");
|
|
58
|
+
style.textContent = text;
|
|
59
|
+
shadow.insertBefore(style, rootNode);
|
|
60
|
+
return style; // To update as the app needs more.
|
|
61
|
+
},
|
|
62
|
+
adoptLink: (href) => {
|
|
63
|
+
const link = doc.createElement("link");
|
|
64
|
+
link.rel = "stylesheet";
|
|
65
|
+
link.href = href;
|
|
66
|
+
shadow.insertBefore(link, rootNode);
|
|
67
|
+
},
|
|
68
|
+
emit: (type, data) =>
|
|
69
|
+
queueMicrotask(() => {
|
|
70
|
+
if (this.current !== detail) return;
|
|
71
|
+
const init = { bubbles: true, detail: { key, type, data } };
|
|
72
|
+
this.dispatchEvent(new window.CustomEvent(REPORT, init));
|
|
73
|
+
}),
|
|
74
|
+
mount: (c) => {
|
|
75
|
+
controller = c;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
this.current = detail;
|
|
79
|
+
this.mountedKey = key;
|
|
80
|
+
this.dispatchEvent(new window.CustomEvent(REQUEST, { bubbles: true, detail }));
|
|
81
|
+
if (controller === null) rootNode.textContent = `no app for ${key}`;
|
|
82
|
+
this.controller = controller;
|
|
83
|
+
}
|
|
84
|
+
dispose() {
|
|
85
|
+
const { controller } = this;
|
|
86
|
+
this.controller = null;
|
|
87
|
+
this.current = null;
|
|
88
|
+
this.mountedKey = null;
|
|
89
|
+
controller?.dispose();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
window.customElements.define(tagName, TutucaApp);
|
|
93
|
+
return TutucaApp;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Mounts a plain Host for a request: register(scope) registers its components, make(C)
|
|
97
|
+
// makes the root from them. Returns the controller, already passed to detail.mount.
|
|
98
|
+
export const mountTutucaApp = (detail, { register, make, dev = false, onNotify }) => {
|
|
99
|
+
const host = new Host({ document: detail.document, rootNode: detail.rootNode, dev });
|
|
100
|
+
register(host.scope);
|
|
101
|
+
if (onNotify !== undefined) host.onNotify = onNotify;
|
|
102
|
+
host.start(make(host.scope.components));
|
|
103
|
+
const controller = {
|
|
104
|
+
host,
|
|
105
|
+
dispose() {
|
|
106
|
+
host.stop();
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
detail.mount(controller);
|
|
110
|
+
return controller;
|
|
111
|
+
};
|
package/src/env.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// What handlers read from the world, through ctx.now(), ctx.random() and ctx.uuid(): the
|
|
2
|
+
// clock in ms since the epoch, a number in [0, 1) and a new id. Each mirrors the global it
|
|
3
|
+
// stands for, so a host can swap them for ones that repeat (testEnv).
|
|
4
|
+
export const browserEnv = {
|
|
5
|
+
now: () => Date.now(),
|
|
6
|
+
random: () => Math.random(),
|
|
7
|
+
uuid: () => globalThis.crypto.randomUUID(),
|
|
8
|
+
};
|
|
9
|
+
// An env whose runs repeat: a clock stopped at start until advance(ms) moves it, numbers from
|
|
10
|
+
// a generator seeded with seed, and uuid-shaped ids that count up.
|
|
11
|
+
export const testEnv = ({ seed = 1, start = Date.UTC(2026, 0, 1) } = {}) => {
|
|
12
|
+
let time = start;
|
|
13
|
+
let state = seed >>> 0;
|
|
14
|
+
let ids = 0;
|
|
15
|
+
return {
|
|
16
|
+
now: () => time,
|
|
17
|
+
advance(ms) {
|
|
18
|
+
time += ms;
|
|
19
|
+
},
|
|
20
|
+
// mulberry32
|
|
21
|
+
random() {
|
|
22
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
23
|
+
let x = state;
|
|
24
|
+
x = Math.imul(x ^ (x >>> 15), x | 1);
|
|
25
|
+
x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
|
|
26
|
+
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
|
|
27
|
+
},
|
|
28
|
+
uuid: () => `00000000-0000-4000-8000-${(++ids).toString(16).padStart(12, "0")}`,
|
|
29
|
+
};
|
|
30
|
+
};
|
package/src/events.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// DOM events: finds the component, path and handlers an event belongs to from the meta comments.
|
|
2
|
+
import { isMetaOpen, META_CLOSE, parseMeta } from "./meta.js";
|
|
3
|
+
import { Address, DispatchPath, Frame } from "./paths.js";
|
|
4
|
+
import { specOf } from "./schema.js";
|
|
5
|
+
|
|
6
|
+
const META_TYPES = new Set(["component", "frame"]);
|
|
7
|
+
function* enclosingNodeMeta(node) {
|
|
8
|
+
let closed = 0;
|
|
9
|
+
for (let n = node.previousSibling; n; n = n.previousSibling) {
|
|
10
|
+
if (n.nodeType !== 8) continue;
|
|
11
|
+
const data = n.data;
|
|
12
|
+
if (data === META_CLOSE) {
|
|
13
|
+
closed++;
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (!isMetaOpen(data)) continue;
|
|
17
|
+
if (closed > 0) {
|
|
18
|
+
closed--;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
let meta;
|
|
22
|
+
try {
|
|
23
|
+
meta = parseMeta(data);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error("bad meta comment", err, n, node);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (META_TYPES.has(meta?.t)) yield meta; // Skip look-alike comments from unmanaged DOM.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// From node up to rootNode: the metas, innermost first; where each component meta is in them;
|
|
32
|
+
// and the elements with handlers, each with the number of components below it. null when
|
|
33
|
+
// node is outside rootNode.
|
|
34
|
+
const walkUp = (node, rootNode) => {
|
|
35
|
+
const metas = [];
|
|
36
|
+
const components = [];
|
|
37
|
+
const eids = [];
|
|
38
|
+
while (node && node !== rootNode) {
|
|
39
|
+
// The attribute, not dataset, which elements outside HTML and SVG (MathML in jsdom) lack.
|
|
40
|
+
const eid = node.getAttribute?.("data-eid");
|
|
41
|
+
if (eid != null) eids.push({ eid: parseInt(eid, 10), node, level: components.length });
|
|
42
|
+
for (const meta of enclosingNodeMeta(node)) {
|
|
43
|
+
if (meta.t === "component") components.push(metas.length);
|
|
44
|
+
metas.push(meta);
|
|
45
|
+
}
|
|
46
|
+
node = node.parentNode;
|
|
47
|
+
}
|
|
48
|
+
return node === rootNode ? { metas, components, eids } : null;
|
|
49
|
+
};
|
|
50
|
+
// The path the metas, innermost first, describe.
|
|
51
|
+
const pathFrom = (metas) => {
|
|
52
|
+
const frames = [];
|
|
53
|
+
for (const meta of [...metas].reverse()) {
|
|
54
|
+
if (meta.t === "frame") frames.push(Frame.fromMeta(meta));
|
|
55
|
+
else if (frames.length > 0) frames.push(frames.pop().asComponent());
|
|
56
|
+
}
|
|
57
|
+
return new DispatchPath(frames);
|
|
58
|
+
};
|
|
59
|
+
// The path of the component, and the loop items in it, node was rendered in.
|
|
60
|
+
export const pathOf = (node, rootNode) => {
|
|
61
|
+
const walk = walkUp(node, rootNode);
|
|
62
|
+
return walk === null || walk.components.length === 0 ? null : pathFrom(walk.metas);
|
|
63
|
+
};
|
|
64
|
+
// The handler for a type event on target, with the address of its component and its element.
|
|
65
|
+
// Only the component target is in handles it, unless bubbles: then the nearest enclosing
|
|
66
|
+
// component with a handler for it does.
|
|
67
|
+
export const getEventInfoFor = (target, type, rootNode, rootComponent, bubbles = false) => {
|
|
68
|
+
const walk = walkUp(target, rootNode);
|
|
69
|
+
if (walk === null) return null;
|
|
70
|
+
const levels = bubbles ? walk.components.length : Math.min(1, walk.components.length);
|
|
71
|
+
for (let level = 0; level < levels; level++) {
|
|
72
|
+
const eids = walk.eids.filter((e) => e.level === level);
|
|
73
|
+
if (eids.length === 0) continue;
|
|
74
|
+
// What is outside the component below: its frame is the child the event came through.
|
|
75
|
+
const start = level === 0 ? 0 : walk.components[level - 1] + 1;
|
|
76
|
+
const to = Address.at(pathFrom(walk.metas.slice(start)), rootComponent);
|
|
77
|
+
const { eventHandlers } = specOf(to.updatePath.getIn(rootComponent));
|
|
78
|
+
for (const { eid, node } of eids) {
|
|
79
|
+
for (const handler of eventHandlers.get(eid) ?? []) {
|
|
80
|
+
if (handler.name === type) return { handler, to, node };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// The time travel bar: ⏮ ◀ ▶ ⏭, a slider over the states and what made the current one. It
|
|
2
|
+
// holds no timeline, it shows the one it's given and asks "history" to move through it, with
|
|
3
|
+
// [target, op, arg], op one of goto, back, forward, toStart and toEnd. mountTimeTravel puts
|
|
4
|
+
// one, with a historyEngine, next to a live Host.
|
|
5
|
+
|
|
6
|
+
import { ORIGINS } from "../storybook/ui/common.js";
|
|
7
|
+
import {
|
|
8
|
+
$if,
|
|
9
|
+
button,
|
|
10
|
+
component,
|
|
11
|
+
DispatchPath,
|
|
12
|
+
field,
|
|
13
|
+
HOST,
|
|
14
|
+
Host,
|
|
15
|
+
send,
|
|
16
|
+
Target,
|
|
17
|
+
Task,
|
|
18
|
+
t,
|
|
19
|
+
tag,
|
|
20
|
+
targetValue,
|
|
21
|
+
} from "../tutuca.js";
|
|
22
|
+
import { historyEngine } from "./index.js";
|
|
23
|
+
|
|
24
|
+
const { div, input, span } = tag;
|
|
25
|
+
|
|
26
|
+
const OPS = ["goto", "back", "forward", "toStart", "toEnd"];
|
|
27
|
+
const BUTTON = "btn btn-xs btn-ghost font-mono";
|
|
28
|
+
const KEYS = { ArrowLeft: "back", ArrowRight: "forward", Home: "toStart", End: "toEnd" };
|
|
29
|
+
|
|
30
|
+
export const TimeTravel = component({
|
|
31
|
+
name: "TimeTravel",
|
|
32
|
+
fields: {
|
|
33
|
+
target: t.string(), // Passed along with each op: the app the timeline belongs to.
|
|
34
|
+
size: t.number().int().min(0),
|
|
35
|
+
cursor: t.number().int().min(0),
|
|
36
|
+
label: t.string(),
|
|
37
|
+
origin: t.string(),
|
|
38
|
+
},
|
|
39
|
+
methods: {
|
|
40
|
+
// One state is not a history to travel through.
|
|
41
|
+
get isShown() {
|
|
42
|
+
return this.size > 1;
|
|
43
|
+
},
|
|
44
|
+
get last() {
|
|
45
|
+
return Math.max(0, this.size - 1);
|
|
46
|
+
},
|
|
47
|
+
get atStart() {
|
|
48
|
+
return this.cursor === 0;
|
|
49
|
+
},
|
|
50
|
+
get atEnd() {
|
|
51
|
+
return this.cursor >= this.last;
|
|
52
|
+
},
|
|
53
|
+
get position() {
|
|
54
|
+
return `state ${this.cursor + 1} of ${this.size}`;
|
|
55
|
+
},
|
|
56
|
+
get icon() {
|
|
57
|
+
return ORIGINS[this.origin] ?? "";
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
view: $if(
|
|
61
|
+
field("isShown"),
|
|
62
|
+
div(
|
|
63
|
+
{
|
|
64
|
+
class: "flex w-full items-center gap-2 text-sm",
|
|
65
|
+
role: "group",
|
|
66
|
+
"aria-label": "Time travel",
|
|
67
|
+
title: "Alt+← and Alt+→ step back and forward",
|
|
68
|
+
"on:keydown": send("key"),
|
|
69
|
+
},
|
|
70
|
+
button(
|
|
71
|
+
{
|
|
72
|
+
class: BUTTON,
|
|
73
|
+
title: "First state",
|
|
74
|
+
disabled: field("atStart"),
|
|
75
|
+
"on:click": send("op", "toStart"),
|
|
76
|
+
},
|
|
77
|
+
"⏮",
|
|
78
|
+
),
|
|
79
|
+
button(
|
|
80
|
+
{
|
|
81
|
+
class: BUTTON,
|
|
82
|
+
title: "Back",
|
|
83
|
+
disabled: field("atStart"),
|
|
84
|
+
"on:click": send("op", "back"),
|
|
85
|
+
},
|
|
86
|
+
"◀",
|
|
87
|
+
),
|
|
88
|
+
button(
|
|
89
|
+
{
|
|
90
|
+
class: BUTTON,
|
|
91
|
+
title: "Forward",
|
|
92
|
+
disabled: field("atEnd"),
|
|
93
|
+
"on:click": send("op", "forward"),
|
|
94
|
+
},
|
|
95
|
+
"▶",
|
|
96
|
+
),
|
|
97
|
+
button(
|
|
98
|
+
{
|
|
99
|
+
class: BUTTON,
|
|
100
|
+
title: "Last state",
|
|
101
|
+
disabled: field("atEnd"),
|
|
102
|
+
"on:click": send("op", "toEnd"),
|
|
103
|
+
},
|
|
104
|
+
"⏭",
|
|
105
|
+
),
|
|
106
|
+
input({
|
|
107
|
+
type: "range",
|
|
108
|
+
class: "range range-xs range-primary w-64 max-w-[40vw] shrink-0",
|
|
109
|
+
"aria-label": "State",
|
|
110
|
+
min: "0",
|
|
111
|
+
max: field("last"),
|
|
112
|
+
value: field("cursor"),
|
|
113
|
+
"on:input": send("goto", targetValue()),
|
|
114
|
+
}),
|
|
115
|
+
span(
|
|
116
|
+
{ class: "min-w-0 flex-1 truncate font-mono text-xs tabular-nums opacity-70" },
|
|
117
|
+
field("position"),
|
|
118
|
+
" · ",
|
|
119
|
+
field("icon"),
|
|
120
|
+
" ",
|
|
121
|
+
field("label"),
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
"",
|
|
125
|
+
),
|
|
126
|
+
on: {
|
|
127
|
+
show: {
|
|
128
|
+
args: [t.number().int().min(0), t.number().int().min(0), t.string(), t.string()],
|
|
129
|
+
run(next, [size, cursor, label, origin]) {
|
|
130
|
+
Object.assign(next, { size, cursor, label, origin });
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
op: {
|
|
134
|
+
args: [t.enum(...OPS)],
|
|
135
|
+
emits: { ask: ["history"] },
|
|
136
|
+
run(_next, [op], ctx) {
|
|
137
|
+
ctx.ask("history", [this.target, op, null]);
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
goto: {
|
|
141
|
+
args: [t.string()],
|
|
142
|
+
emits: { ask: ["history"] },
|
|
143
|
+
run(_next, [value], ctx) {
|
|
144
|
+
const i = Number(value);
|
|
145
|
+
if (Number.isInteger(i)) ctx.ask("history", [this.target, "goto", i]);
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
// Alt+← and Alt+→ step, Alt+Home and Alt+End jump to the ends.
|
|
149
|
+
key: {
|
|
150
|
+
args: [],
|
|
151
|
+
emits: { ask: ["history"] },
|
|
152
|
+
run(_next, _args, ctx) {
|
|
153
|
+
const op = ctx.event?.altKey ? KEYS[ctx.event.key] : undefined;
|
|
154
|
+
if (op === undefined) return;
|
|
155
|
+
ctx.event.preventDefault?.();
|
|
156
|
+
ctx.ask("history", [this.target, op, null]);
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
answers: {
|
|
161
|
+
history: {
|
|
162
|
+
ok() {},
|
|
163
|
+
error(_next, [error]) {
|
|
164
|
+
console.error("time travel failed", error);
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// What the bar shows of a Timeline, as the args of its show message.
|
|
171
|
+
export const showArgs = ({ size, cursor, current }) => [
|
|
172
|
+
size,
|
|
173
|
+
cursor,
|
|
174
|
+
current?.label ?? "",
|
|
175
|
+
current?.origin ?? "",
|
|
176
|
+
];
|
|
177
|
+
// Sends name(...args) to host's root component from outside the app.
|
|
178
|
+
const sendRoot = (host, name, args) => {
|
|
179
|
+
const task = new Task(name, args, HOST, new Target(new DispatchPath()), null);
|
|
180
|
+
task.prepare(host.rootComponent);
|
|
181
|
+
host.pushTask(task);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Records host's timeline with a historyEngine of at most max states and shows a TimeTravel
|
|
185
|
+
// bar for it in rootNode, on a Host of its own; returns { engine, bar, stop }.
|
|
186
|
+
export const mountTimeTravel = ({ document, rootNode, host, max = 1000 }) => {
|
|
187
|
+
const bar = new Host({ document, rootNode });
|
|
188
|
+
bar.scope.register({ TimeTravel });
|
|
189
|
+
// A message, not a new root: travel runs while the bar handles its ask.
|
|
190
|
+
const show = (timeline) => sendRoot(bar, "show", showArgs(timeline));
|
|
191
|
+
const engine = historyEngine({ max, onChange: show });
|
|
192
|
+
bar.scope.registerAskHandlers({
|
|
193
|
+
history: ([_target, op, arg]) => (op === "goto" ? engine.travel(arg) : engine[op]()),
|
|
194
|
+
});
|
|
195
|
+
bar.start(bar.scope.components.TimeTravel());
|
|
196
|
+
host.attachSync(engine);
|
|
197
|
+
return { engine, bar, stop: () => bar.stop() };
|
|
198
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Time travel: a Timeline of states with a cursor, and historyEngine, which records one for a
|
|
2
|
+
// live Host as a sync engine and travels by applying a recorded state as a change from
|
|
3
|
+
// elsewhere, so the other engines (storage, peers) follow.
|
|
4
|
+
|
|
5
|
+
import { truncate } from "../dev/jsonml.js";
|
|
6
|
+
import { jsonText } from "../paths.js";
|
|
7
|
+
import { EventTask } from "../tasks.js";
|
|
8
|
+
|
|
9
|
+
export const argsLabel = (args) => truncate(args.map(jsonText).join(", "), 80);
|
|
10
|
+
// The task a task's chain started with: an event, or a message sent from outside.
|
|
11
|
+
export const rootTask = (task) => {
|
|
12
|
+
while (task.parent !== null) task = task.parent;
|
|
13
|
+
return task;
|
|
14
|
+
};
|
|
15
|
+
// A task as a timeline shows it, like "click → toggle()".
|
|
16
|
+
export const taskLabel = (task) => {
|
|
17
|
+
const call = `${task.name}(${argsLabel(task.args)})`;
|
|
18
|
+
return task instanceof EventTask ? `${task.event.type} → ${call}` : call;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// The left entry of the two closest in time, the one to drop to keep the rest spread out,
|
|
22
|
+
// sparing the first and the current; -1 when every one is spared.
|
|
23
|
+
const closestLeft = (entries, cursor) => {
|
|
24
|
+
let found = -1;
|
|
25
|
+
let closest = Infinity;
|
|
26
|
+
for (let i = 2; i < entries.length; i++) {
|
|
27
|
+
const left = i - 1;
|
|
28
|
+
if (left === cursor) continue;
|
|
29
|
+
const gap = entries[i].at - entries[left].at;
|
|
30
|
+
// <= drops the most recent of equally close pairs, keeping older ones.
|
|
31
|
+
if (gap <= closest) {
|
|
32
|
+
closest = gap;
|
|
33
|
+
found = left;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return found;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// States in order, entries { state, label, origin, at, ... }, and a cursor at the one shown.
|
|
40
|
+
// Recording after going back drops the entries after the cursor. Past max entries it drops
|
|
41
|
+
// one of the two closest in time, so a burst of changes thins out but the history keeps its
|
|
42
|
+
// spread; the first entry and the current one stay.
|
|
43
|
+
export class Timeline {
|
|
44
|
+
constructor({ max = 1000, now = Date.now } = {}) {
|
|
45
|
+
this.max = max;
|
|
46
|
+
this.now = now;
|
|
47
|
+
this.entries = [];
|
|
48
|
+
this.cursor = 0;
|
|
49
|
+
}
|
|
50
|
+
get size() {
|
|
51
|
+
return this.entries.length;
|
|
52
|
+
}
|
|
53
|
+
get current() {
|
|
54
|
+
return this.entries[this.cursor];
|
|
55
|
+
}
|
|
56
|
+
get atEnd() {
|
|
57
|
+
return this.cursor === this.entries.length - 1;
|
|
58
|
+
}
|
|
59
|
+
begin(entry) {
|
|
60
|
+
this.entries = [{ at: this.now(), ...entry }];
|
|
61
|
+
this.cursor = 0;
|
|
62
|
+
return this.current;
|
|
63
|
+
}
|
|
64
|
+
record(entry) {
|
|
65
|
+
this.entries.length = this.cursor + 1;
|
|
66
|
+
this.entries.push({ at: this.now(), ...entry });
|
|
67
|
+
this.cursor = this.entries.length - 1;
|
|
68
|
+
while (this.entries.length > this.max) {
|
|
69
|
+
const i = closestLeft(this.entries, this.cursor);
|
|
70
|
+
if (i < 0) break;
|
|
71
|
+
this.entries.splice(i, 1);
|
|
72
|
+
if (i < this.cursor) this.cursor--;
|
|
73
|
+
}
|
|
74
|
+
return this.current;
|
|
75
|
+
}
|
|
76
|
+
// Folds task into the current entry when it's the last one and task continues the chain
|
|
77
|
+
// that made it, as async replies do; false when task starts an entry of its own.
|
|
78
|
+
merge(task, state) {
|
|
79
|
+
const entry = this.current;
|
|
80
|
+
if (!this.atEnd || entry?.rootTask === undefined || entry.rootTask !== rootTask(task)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
entry.state = state;
|
|
84
|
+
entry.tasks = [...(entry.tasks ?? []), task];
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
goto(i) {
|
|
88
|
+
this.cursor = Math.max(0, Math.min(Number(i), this.entries.length - 1));
|
|
89
|
+
return this.current;
|
|
90
|
+
}
|
|
91
|
+
back() {
|
|
92
|
+
return this.goto(this.cursor - 1);
|
|
93
|
+
}
|
|
94
|
+
forward() {
|
|
95
|
+
return this.goto(this.cursor + 1);
|
|
96
|
+
}
|
|
97
|
+
toStart() {
|
|
98
|
+
return this.goto(0);
|
|
99
|
+
}
|
|
100
|
+
toEnd() {
|
|
101
|
+
return this.goto(this.entries.length - 1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Records the timeline of the Host it's attached to (host.attachSync(engine)): one entry per
|
|
106
|
+
// user action, its async replies folded in, and one per change from another engine. The
|
|
107
|
+
// first entry is the state before the first change. travel(i) clears the queued tasks and
|
|
108
|
+
// applies entry i as a change from this engine, so it isn't recorded and every other engine
|
|
109
|
+
// sees it; an async reply that lands after it starts a new branch from there. onChange gets
|
|
110
|
+
// the timeline after each change and travel.
|
|
111
|
+
export const historyEngine = ({ max = 1000, now = Date.now, onChange = () => {} } = {}) => {
|
|
112
|
+
const timeline = new Timeline({ max, now });
|
|
113
|
+
let link = null;
|
|
114
|
+
const seed = (state) => {
|
|
115
|
+
if (timeline.size === 0) timeline.begin({ state, label: "initial", origin: "initial" });
|
|
116
|
+
};
|
|
117
|
+
const travel = (move) => {
|
|
118
|
+
if (link === null || timeline.size === 0) return;
|
|
119
|
+
const { state } = move();
|
|
120
|
+
link.host.clearTasks();
|
|
121
|
+
link.apply(() => state);
|
|
122
|
+
onChange(timeline);
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
timeline,
|
|
126
|
+
attach(l) {
|
|
127
|
+
link = l;
|
|
128
|
+
},
|
|
129
|
+
detach() {
|
|
130
|
+
link = null;
|
|
131
|
+
},
|
|
132
|
+
commit({ prev, next, task }) {
|
|
133
|
+
seed(prev);
|
|
134
|
+
if (task === null) {
|
|
135
|
+
timeline.record({ state: next, label: "synced change", origin: "external" });
|
|
136
|
+
} else if (!timeline.merge(task, next)) {
|
|
137
|
+
const root = rootTask(task);
|
|
138
|
+
const label = taskLabel(root);
|
|
139
|
+
timeline.record({ state: next, label, origin: "user", rootTask: root, tasks: [task] });
|
|
140
|
+
}
|
|
141
|
+
onChange(timeline);
|
|
142
|
+
},
|
|
143
|
+
travel: (i) => travel(() => timeline.goto(i)),
|
|
144
|
+
back: () => travel(() => timeline.back()),
|
|
145
|
+
forward: () => travel(() => timeline.forward()),
|
|
146
|
+
toStart: () => travel(() => timeline.toStart()),
|
|
147
|
+
toEnd: () => travel(() => timeline.toEnd()),
|
|
148
|
+
};
|
|
149
|
+
};
|