nodality 1.1.10 → 1.1.13
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/README.md +71 -1
- package/dist/animator.cjs.js +1 -1
- package/dist/animator.cjs.js.LICENSE.txt +1 -1
- package/dist/animator.esm.js.LICENSE.txt +1 -1
- package/dist/audionew.cjs.js +1 -1
- package/dist/audionew.cjs.js.LICENSE.txt +1 -1
- package/dist/audionew.esm.js.LICENSE.txt +1 -1
- package/dist/base.cjs.js +1 -1
- package/dist/base.cjs.js.LICENSE.txt +1 -1
- package/dist/base.esm.js.LICENSE.txt +1 -1
- package/dist/beta-desktop-bar.cjs.js +1 -1
- package/dist/beta-desktop-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/beta-desktop-bar.esm.js.LICENSE.txt +1 -1
- package/dist/beta-mobile-bar.cjs.js +1 -1
- package/dist/beta-mobile-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/beta-mobile-bar.esm.js.LICENSE.txt +1 -1
- package/dist/bundle.umd.js +1 -1
- package/dist/bundle.umd.js.LICENSE.txt +1 -1
- package/dist/button.cjs.js +1 -1
- package/dist/button.cjs.js.LICENSE.txt +1 -1
- package/dist/button.esm.js.LICENSE.txt +1 -1
- package/dist/card-getter.cjs.js.LICENSE.txt +1 -1
- package/dist/card-getter.esm.js.LICENSE.txt +1 -1
- package/dist/center.cjs.js +1 -1
- package/dist/center.cjs.js.LICENSE.txt +1 -1
- package/dist/center.esm.js.LICENSE.txt +1 -1
- package/dist/checkbox.cjs.js +1 -1
- package/dist/checkbox.cjs.js.LICENSE.txt +1 -1
- package/dist/checkbox.esm.js.LICENSE.txt +1 -1
- package/dist/code.cjs.js +1 -1
- package/dist/code.cjs.js.LICENSE.txt +1 -1
- package/dist/code.esm.js.LICENSE.txt +1 -1
- package/dist/container.cjs.js +1 -1
- package/dist/container.cjs.js.LICENSE.txt +1 -1
- package/dist/container.esm.js.LICENSE.txt +1 -1
- package/dist/data-list.cjs.js.LICENSE.txt +1 -1
- package/dist/data-list.esm.js.LICENSE.txt +1 -1
- package/dist/designer.cjs.js +1 -1
- package/dist/designer.cjs.js.LICENSE.txt +1 -1
- package/dist/designer.esm.js +1 -1
- package/dist/designer.esm.js.LICENSE.txt +1 -1
- package/dist/element-mapper.cjs.js +1 -1
- package/dist/element-mapper.cjs.js.LICENSE.txt +1 -1
- package/dist/element-mapper.esm.js.LICENSE.txt +1 -1
- package/dist/finalresult.esm.js +1 -1
- package/dist/finalresult.esm.js.LICENSE.txt +1 -1
- package/dist/flex-card.cjs.js +1 -1
- package/dist/flex-card.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-card.esm.js.LICENSE.txt +1 -1
- package/dist/flex-grid.cjs.js +1 -1
- package/dist/flex-grid.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-grid.esm.js.LICENSE.txt +1 -1
- package/dist/flex-row.cjs.js +1 -1
- package/dist/flex-row.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-row.esm.js.LICENSE.txt +1 -1
- package/dist/floating-input.cjs.js +1 -1
- package/dist/floating-input.cjs.js.LICENSE.txt +1 -1
- package/dist/floating-input.esm.js.LICENSE.txt +1 -1
- package/dist/free.cjs.js +1 -1
- package/dist/free.cjs.js.LICENSE.txt +1 -1
- package/dist/free.esm.js.LICENSE.txt +1 -1
- package/dist/horizontal-scroller.cjs.js.LICENSE.txt +1 -1
- package/dist/horizontal-scroller.esm.js.LICENSE.txt +1 -1
- package/dist/image-picker.cjs.js +1 -1
- package/dist/image-picker.cjs.js.LICENSE.txt +1 -1
- package/dist/image-picker.esm.js.LICENSE.txt +1 -1
- package/dist/image.cjs.js +1 -1
- package/dist/image.cjs.js.LICENSE.txt +1 -1
- package/dist/image.esm.js.LICENSE.txt +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.cjs.js.LICENSE.txt +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.LICENSE.txt +1 -1
- package/dist/keyframe-animation.cjs.js.LICENSE.txt +1 -1
- package/dist/keyframe-animation.esm.js.LICENSE.txt +1 -1
- package/dist/link-getter.cjs.js +1 -1
- package/dist/link-getter.cjs.js.LICENSE.txt +1 -1
- package/dist/link-getter.esm.js.LICENSE.txt +1 -1
- package/dist/link.cjs.js +1 -1
- package/dist/link.cjs.js.LICENSE.txt +1 -1
- package/dist/link.esm.js.LICENSE.txt +1 -1
- package/dist/meta-adder.cjs.js +1 -1
- package/dist/meta-adder.cjs.js.LICENSE.txt +1 -1
- package/dist/meta-adder.esm.js.LICENSE.txt +1 -1
- package/dist/modal-2025.cjs.js +1 -1
- package/dist/modal-2025.cjs.js.LICENSE.txt +1 -1
- package/dist/modal-2025.esm.js.LICENSE.txt +1 -1
- package/dist/multiswitcher.cjs.js +1 -1
- package/dist/multiswitcher.cjs.js.LICENSE.txt +1 -1
- package/dist/multiswitcher.esm.js.LICENSE.txt +1 -1
- package/dist/new-nav-bar.cjs.js +1 -1
- package/dist/new-nav-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/new-nav-bar.esm.js.LICENSE.txt +1 -1
- package/dist/picker.cjs.js +1 -1
- package/dist/picker.cjs.js.LICENSE.txt +1 -1
- package/dist/picker.esm.js.LICENSE.txt +1 -1
- package/dist/progress.cjs.js +1 -1
- package/dist/progress.cjs.js.LICENSE.txt +1 -1
- package/dist/progress.esm.js.LICENSE.txt +1 -1
- package/dist/radio.cjs.js.LICENSE.txt +1 -1
- package/dist/radio.esm.js.LICENSE.txt +1 -1
- package/dist/range.cjs.js.LICENSE.txt +1 -1
- package/dist/range.esm.js.LICENSE.txt +1 -1
- package/dist/scroll-video.cjs.js.LICENSE.txt +1 -1
- package/dist/scroll-video.esm.js.LICENSE.txt +1 -1
- package/dist/side-bar.cjs.js +1 -1
- package/dist/side-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/side-bar.esm.js.LICENSE.txt +1 -1
- package/dist/side-nav-bar.cjs.js +1 -1
- package/dist/side-nav-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/side-nav-bar.esm.js.LICENSE.txt +1 -1
- package/dist/simple-bar.cjs.js +1 -1
- package/dist/simple-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/simple-bar.esm.js.LICENSE.txt +1 -1
- package/dist/slider-2025.cjs.js +1 -1
- package/dist/slider-2025.cjs.js.LICENSE.txt +1 -1
- package/dist/slider-2025.esm.js.LICENSE.txt +1 -1
- package/dist/spacer.cjs.js +1 -1
- package/dist/spacer.cjs.js.LICENSE.txt +1 -1
- package/dist/spacer.esm.js.LICENSE.txt +1 -1
- package/dist/stack.cjs.js +1 -1
- package/dist/stack.cjs.js.LICENSE.txt +1 -1
- package/dist/stack.esm.js.LICENSE.txt +1 -1
- package/dist/stacker.cjs.js.LICENSE.txt +1 -1
- package/dist/stacker.esm.js.LICENSE.txt +1 -1
- package/dist/table.cjs.js +1 -1
- package/dist/table.cjs.js.LICENSE.txt +1 -1
- package/dist/table.esm.js.LICENSE.txt +1 -1
- package/dist/text-field.cjs.js +1 -1
- package/dist/text-field.cjs.js.LICENSE.txt +1 -1
- package/dist/text-field.esm.js.LICENSE.txt +1 -1
- package/dist/text.cjs.js +1 -1
- package/dist/text.cjs.js.LICENSE.txt +1 -1
- package/dist/text.esm.js.LICENSE.txt +1 -1
- package/dist/theme.cjs.js.LICENSE.txt +1 -1
- package/dist/theme.esm.js.LICENSE.txt +1 -1
- package/dist/transform-anim.cjs.js.LICENSE.txt +1 -1
- package/dist/transform-anim.esm.js.LICENSE.txt +1 -1
- package/dist/ulist.cjs.js +1 -1
- package/dist/ulist.cjs.js.LICENSE.txt +1 -1
- package/dist/ulist.esm.js.LICENSE.txt +1 -1
- package/dist/video.cjs.js +1 -1
- package/dist/video.cjs.js.LICENSE.txt +1 -1
- package/dist/video.esm.js.LICENSE.txt +1 -1
- package/dist/wrap.cjs.js +1 -1
- package/dist/wrap.cjs.js.LICENSE.txt +1 -1
- package/dist/wrap.esm.js.LICENSE.txt +1 -1
- package/dist/zoom-card.cjs.js +1 -1
- package/dist/zoom-card.cjs.js.LICENSE.txt +1 -1
- package/dist/zoom-card.esm.js.LICENSE.txt +1 -1
- package/layout/animator.js +1 -1
- package/layout/audio.js +1 -1
- package/layout/audionew.js +1 -1
- package/layout/base.js +1 -1
- package/layout/beta-desktop-bar.js +1 -1
- package/layout/beta-mobile-bar.js +1 -1
- package/layout/button.js +1 -1
- package/layout/center.js +1 -1
- package/layout/checkbox.js +1 -1
- package/layout/circle.js +1 -1
- package/layout/code.js +1 -1
- package/layout/container.js +1 -1
- package/layout/dropdown-2025.js +1 -1
- package/layout/flex-card.js +1 -1
- package/layout/flex-grid.js +1 -1
- package/layout/flex-row.js +1 -1
- package/layout/form-components/custom.js +1 -1
- package/layout/form-components/data-list.js +1 -1
- package/layout/form-components/floating-input.js +1 -1
- package/layout/form-components/form-all.js +1 -1
- package/layout/form-components/form.js +1 -1
- package/layout/form-components/image-picker.js +1 -1
- package/layout/form-components/picker.js +1 -1
- package/layout/form-components/radio.js +1 -1
- package/layout/form-components/radiogroup.js +1 -1
- package/layout/form-components/range.js +1 -1
- package/layout/free.js +1 -1
- package/layout/grid-switcher.js +1 -1
- package/layout/grid.js +1 -1
- package/layout/horizontal-scroller.js +1 -1
- package/layout/image.js +1 -1
- package/layout/index.js +1 -1
- package/layout/link.js +1 -1
- package/layout/list.js +1 -1
- package/layout/meta-adder.js +1 -1
- package/layout/modal-2025.js +1 -1
- package/layout/morph.js +1 -1
- package/layout/multiswitcher.js +1 -1
- package/layout/nav-bar.js +1 -1
- package/layout/nav-factor/custom-div.js +1 -1
- package/layout/new-nav-bar.js +1 -1
- package/layout/polygon.js +1 -1
- package/layout/prerender-site.js +1 -1
- package/layout/prerender.js +1 -1
- package/layout/progress.js +1 -1
- package/layout/row.js +1 -1
- package/layout/scroll-video.js +1 -1
- package/layout/side-bar.js +1 -1
- package/layout/side-nav-bar.js +1 -1
- package/layout/simple-bar.js +1 -1
- package/layout/slider-2025.js +1 -1
- package/layout/spacer.js +1 -1
- package/layout/stack.js +1 -1
- package/layout/svg.js +1 -1
- package/layout/switcher.js +1 -1
- package/layout/table.js +1 -1
- package/layout/text-field.js +1 -1
- package/layout/text.js +1 -1
- package/layout/ulist.js +1 -1
- package/layout/video.js +1 -1
- package/layout/wrap.js +1 -1
- package/layout/zoom-card.js +1 -1
- package/lib/card-getter.js +1 -1
- package/lib/codegen.js +1 -1
- package/lib/data.js +1 -1
- package/lib/designer.js +1 -1
- package/lib/element-mapper.js +1 -1
- package/lib/keyframe-animation.js +1 -1
- package/lib/link-getter.js +1 -1
- package/lib/morph-node.js +487 -116
- package/lib/raster-inspect.js +1 -1
- package/lib/raster-ops.js +10 -2
- package/lib/raster-presets.js +1 -1
- package/lib/scroll-video.js +1 -1
- package/lib/seo.js +1 -1
- package/lib/stacker.js +1 -1
- package/lib/suggest.js +1 -1
- package/lib/theme.js +1 -1
- package/lib/transform-anim.js +1 -1
- package/lib/transition.js +1 -1
- package/lib/validate-nodes.js +1 -1
- package/package.json +1 -1
package/lib/morph-node.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* nodality v1.1.
|
|
2
|
+
* nodality v1.1.13
|
|
3
3
|
* (c) 2026 Filip Vabrousek
|
|
4
4
|
* License: MIT
|
|
5
5
|
*/
|
|
@@ -121,6 +121,80 @@ const chainFor = (effect) => {
|
|
|
121
121
|
try { return preset(effect) || []; } catch (e) { return []; }
|
|
122
122
|
};
|
|
123
123
|
|
|
124
|
+
const norm = (t) => String(t || "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A morph node, in one shape: a list of EDGES.
|
|
128
|
+
*
|
|
129
|
+
* A node is written either as a single hop —
|
|
130
|
+
*
|
|
131
|
+
* { op: "morph", from: "topnav", to: { About: "about" } }
|
|
132
|
+
*
|
|
133
|
+
* — or as a chain, which is the same thing with more than one edge:
|
|
134
|
+
*
|
|
135
|
+
* { op: "morph", effect: "t-vhs", duration: 900, back: true,
|
|
136
|
+
* chain: [ { from: "topnav", to: { About: "about" } },
|
|
137
|
+
* { from: "about", to: { Detail: "about-detail" } } ] }
|
|
138
|
+
*
|
|
139
|
+
* Both arrive here as a list, so there is exactly one code path below and
|
|
140
|
+
* the single hop is not a special case of anything. `chain` entries are
|
|
141
|
+
* EDGES rather than keyframes: entry two does not happen after entry one,
|
|
142
|
+
* it is reachable FROM the state entry one lands on. That is why they
|
|
143
|
+
* carry no ordinal — array order is already order, and a number would
|
|
144
|
+
* claim a sequence the graph does not have.
|
|
145
|
+
*
|
|
146
|
+
* Per-edge options fall back to the node, so the common settings are
|
|
147
|
+
* written once and an edge overrides only what differs.
|
|
148
|
+
*/
|
|
149
|
+
const normalizeEdges = (m) => {
|
|
150
|
+
const raw = Array.isArray(m.chain) && m.chain.length
|
|
151
|
+
? m.chain
|
|
152
|
+
: [{ from: m.from, to: m.to }];
|
|
153
|
+
|
|
154
|
+
return raw.map((e) => {
|
|
155
|
+
const opt = (key, fallback) =>
|
|
156
|
+
(e[key] !== undefined ? e[key]
|
|
157
|
+
: m[key] !== undefined ? m[key] : fallback);
|
|
158
|
+
|
|
159
|
+
// `to` takes two forms, and the object form is the one that
|
|
160
|
+
// survives a real navbar:
|
|
161
|
+
//
|
|
162
|
+
// ["work", "about"] by POSITION — link i goes to i
|
|
163
|
+
// { Work: "work", About: "about" } by LABEL — link text decides
|
|
164
|
+
//
|
|
165
|
+
// Position looks tidier and is wrong for anything but a toy nav. A
|
|
166
|
+
// responsive bar renders a DIFFERENT set of links per breakpoint
|
|
167
|
+
// (About/Services/Contact wide, About/About/Contact collapsed), so
|
|
168
|
+
// index i is not the same destination at two window sizes. And a
|
|
169
|
+
// nav that repeats a label sends two links to one arbitrary place.
|
|
170
|
+
// Labels are what the user is actually reading and clicking.
|
|
171
|
+
const byLabel = e.to && !Array.isArray(e.to) && typeof e.to === "object"
|
|
172
|
+
? e.to : null;
|
|
173
|
+
const toIds = byLabel
|
|
174
|
+
? [...new Set(Object.values(e.to))]
|
|
175
|
+
: (Array.isArray(e.to) ? e.to : [e.to]);
|
|
176
|
+
|
|
177
|
+
const labelToId = new Map();
|
|
178
|
+
if (byLabel) {
|
|
179
|
+
for (const [label, id] of Object.entries(e.to)) {
|
|
180
|
+
labelToId.set(norm(label), id);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
from: e.from, byLabel, toIds, labelToId,
|
|
186
|
+
effect: opt("effect"),
|
|
187
|
+
duration: opt("duration", 900),
|
|
188
|
+
// Written as `!== false` rather than a truthy test: the
|
|
189
|
+
// documented default is on, and only an explicit false is a
|
|
190
|
+
// request to leave the destination without a way back.
|
|
191
|
+
back: opt("back", true) !== false,
|
|
192
|
+
live: opt("live") === true,
|
|
193
|
+
fade: opt("fade", "morph"),
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
|
|
124
198
|
/**
|
|
125
199
|
* @param {Element} mount the container Des rendered into
|
|
126
200
|
* @param {object[]} elements the E array, in order
|
|
@@ -148,39 +222,43 @@ function applyMorphNodes(mount, elements, nodes) {
|
|
|
148
222
|
}
|
|
149
223
|
|
|
150
224
|
function setUpMorph(mount, m, resolve) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
// `to` takes two forms, and the object form is the one that survives
|
|
154
|
-
// a real navbar:
|
|
225
|
+
// ── the controller's model ───────────────────────────────────────
|
|
155
226
|
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
227
|
+
// One controller owns one stage and EVERY state in the graph. The
|
|
228
|
+
// alternative — a controller per edge — cannot work: edge two's
|
|
229
|
+
// source is edge one's destination, which the first controller has
|
|
230
|
+
// already detached as a template, so two controllers would fight over
|
|
231
|
+
// the same element and stack two block-level stages.
|
|
158
232
|
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const labelToIndex = new Map();
|
|
177
|
-
if (byLabel) {
|
|
178
|
-
for (const [label, id] of Object.entries(m.to)) {
|
|
179
|
-
const i = toIds.indexOf(id);
|
|
180
|
-
if (i >= 0) labelToIndex.set(norm(label), i);
|
|
233
|
+
// States are keyed by id rather than held in a positional array,
|
|
234
|
+
// because an edge names its destinations by id and a chain's second
|
|
235
|
+
// edge starts from a state the first edge introduced. Position means
|
|
236
|
+
// nothing once there is more than one source.
|
|
237
|
+
const edges = normalizeEdges(m);
|
|
238
|
+
const rootEdge = edges[0];
|
|
239
|
+
const rootId = rootEdge && rootEdge.from;
|
|
240
|
+
const rootEl = rootId != null ? resolve(rootId) : null;
|
|
241
|
+
|
|
242
|
+
const states = new Map();
|
|
243
|
+
if (rootEl) states.set(rootId, rootEl);
|
|
244
|
+
for (const e of edges) {
|
|
245
|
+
const src = resolve(e.from);
|
|
246
|
+
if (src && !states.has(e.from)) states.set(e.from, src);
|
|
247
|
+
for (const id of e.toIds) {
|
|
248
|
+
const el = resolve(id);
|
|
249
|
+
if (el && !states.has(id)) states.set(id, el);
|
|
181
250
|
}
|
|
182
251
|
}
|
|
183
252
|
|
|
253
|
+
if (!rootEl || !rootEdge.toIds.some((id) => states.has(id))) {
|
|
254
|
+
console.warn("[nodality] morph: could not resolve",
|
|
255
|
+
rootId, "->", rootEdge ? rootEdge.toIds : []);
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The edge leaving a state, or null where the graph ends. */
|
|
260
|
+
const edgeFrom = (id) => edges.find((e) => e.from === id) || null;
|
|
261
|
+
|
|
184
262
|
// A positioned stage, standing where the source stood. The source and
|
|
185
263
|
// the destinations are siblings inside it rather than in page flow,
|
|
186
264
|
// because a morph interpolates a BOX and both ends need a common
|
|
@@ -188,7 +266,7 @@ function setUpMorph(mount, m, resolve) {
|
|
|
188
266
|
const stage = document.createElement("div");
|
|
189
267
|
stage.className = "nod-morph";
|
|
190
268
|
stage.style.cssText = "position:relative;";
|
|
191
|
-
mount.insertBefore(stage,
|
|
269
|
+
mount.insertBefore(stage, rootEl);
|
|
192
270
|
|
|
193
271
|
const rhost = document.createElement("div");
|
|
194
272
|
rhost.className = "nod-morph-host";
|
|
@@ -202,55 +280,82 @@ function setUpMorph(mount, m, resolve) {
|
|
|
202
280
|
stage.appendChild(rhost);
|
|
203
281
|
stage.appendChild(live);
|
|
204
282
|
|
|
205
|
-
live.appendChild(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
//
|
|
210
|
-
// stacked under the nav, which is the whole "why is
|
|
211
|
-
// visible at once" problem.
|
|
212
|
-
const
|
|
213
|
-
if (
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
283
|
+
live.appendChild(rootEl);
|
|
284
|
+
rootEl.style.position = "absolute";
|
|
285
|
+
rootEl.style.left = rootEl.style.top = "0";
|
|
286
|
+
|
|
287
|
+
// Every state except the root starts OFF the page. Without this every
|
|
288
|
+
// subview renders stacked under the nav, which is the whole "why is
|
|
289
|
+
// everything visible at once" problem.
|
|
290
|
+
for (const [id, el] of states) {
|
|
291
|
+
if (id !== rootId) el.remove();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Where the graph is now. `current` moves only when a transition
|
|
295
|
+
// COMPLETES — a morph that is interrupted half way has not arrived
|
|
296
|
+
// anywhere, and treating it as though it had is what makes interrupt
|
|
297
|
+
// handling incoherent. The history stack is the record of states
|
|
298
|
+
// actually landed on, which is what `back` unwinds.
|
|
299
|
+
let current = rootId;
|
|
300
|
+
const history = [];
|
|
301
|
+
|
|
302
|
+
const currentEl = () => states.get(current) || rootEl;
|
|
217
303
|
|
|
218
304
|
const sizeStage = (h) => {
|
|
219
305
|
stage.style.minHeight =
|
|
220
|
-
Math.max(
|
|
306
|
+
Math.max(currentEl().getBoundingClientRect().height, h || 0) + 24 + "px";
|
|
221
307
|
};
|
|
222
308
|
sizeStage(0);
|
|
223
309
|
|
|
224
310
|
let pipe = null;
|
|
225
311
|
let tl = null;
|
|
226
312
|
let active = -1;
|
|
313
|
+
// The two elements a running transition is between. The frame loop
|
|
314
|
+
// needs them by identity rather than by position in the host: from
|
|
315
|
+
// the second hop onward the outgoing state is ALSO a child of the
|
|
316
|
+
// host, so "the first child" no longer means "the destination".
|
|
317
|
+
let incoming = null;
|
|
318
|
+
let outgoing = null;
|
|
319
|
+
// The pair the CURRENT pipeline was built from, as state ids. A
|
|
320
|
+
// reversal may reuse that pipeline only when it is already exactly
|
|
321
|
+
// the hop being unwound — keying the decision on the history top
|
|
322
|
+
// instead let a stale pipeline satisfy it, resolve at zero
|
|
323
|
+
// immediately, and move the graph without moving the screen.
|
|
324
|
+
let lastFrom = null;
|
|
325
|
+
// Set while the root is being measured for a reversal INTO it. The
|
|
326
|
+
// root lives in the live layer, which is display:none whenever the
|
|
327
|
+
// graph is deeper in — and an element inside display:none has no box
|
|
328
|
+
// at all, so capturing it without this yields a 0x0 still and a
|
|
329
|
+
// transition that reverses into nothing.
|
|
330
|
+
let measuring = false;
|
|
331
|
+
let pipeOld = null; // state id on the pipeline's old side
|
|
332
|
+
let pipeNew = null; // state id on its new side
|
|
333
|
+
|
|
334
|
+
// A chain replaces one view with another in the same place; a single
|
|
335
|
+
// hop opens its destination BELOW the source and has done since the
|
|
336
|
+
// first morph shipped. gesos.cz renders with that placement today, so
|
|
337
|
+
// it stays exactly as it was, and only a genuine chain changes it —
|
|
338
|
+
// marching each successive state further down the page would also
|
|
339
|
+
// break hop two's arithmetic, which assumes a source at the top.
|
|
340
|
+
const inPlace = edges.length > 1;
|
|
227
341
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
rhost.appendChild(dest);
|
|
238
|
-
// Hidden from the moment it enters the document. Between insertion
|
|
239
|
-
// and applyRasterPipeline there are two awaited captures, and a
|
|
240
|
-
// destination that is merely in the DOM paints for those frames —
|
|
241
|
-
// which is the destination flashing before the morph runs.
|
|
242
|
-
// visibility (not display) so it still lays out and can be measured.
|
|
243
|
-
dest.style.visibility = "hidden";
|
|
244
|
-
|
|
342
|
+
/**
|
|
343
|
+
* Capture two states and stand a transition pipeline up between them.
|
|
344
|
+
*
|
|
345
|
+
* Shared by both directions. A reversal is not a forward transition
|
|
346
|
+
* to an earlier state: it is THIS, with the state being returned to
|
|
347
|
+
* passed as the old side, so the effect runs backwards rather than
|
|
348
|
+
* playing forwards into the past.
|
|
349
|
+
*/
|
|
350
|
+
const buildTransition = async (oldEl, newEl, edge) => {
|
|
245
351
|
// WAIT FOR IMAGES before measuring anything.
|
|
246
352
|
//
|
|
247
353
|
// An <img> has no intrinsic box until it decodes — even from a
|
|
248
354
|
// data: URL, which is async too. Measure before that and the
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
const imgs = [...dest.querySelectorAll("img")];
|
|
355
|
+
// rect is the pre-image layout, so the shader draws the capture
|
|
356
|
+
// into a box that does not match where the real card ends up.
|
|
357
|
+
// The handover then pops between two different sizes.
|
|
358
|
+
const imgs = [...newEl.querySelectorAll("img")];
|
|
254
359
|
if (imgs.length) {
|
|
255
360
|
await Promise.all(imgs.map((im) => {
|
|
256
361
|
if (im.complete && im.naturalWidth) return null;
|
|
@@ -260,52 +365,268 @@ function setUpMorph(mount, m, resolve) {
|
|
|
260
365
|
im.addEventListener("error", res, { once: true });
|
|
261
366
|
});
|
|
262
367
|
}));
|
|
263
|
-
// One more frame so layout reflects the decoded intrinsic size.
|
|
264
368
|
await new Promise((r) => requestAnimationFrame(r));
|
|
265
369
|
}
|
|
266
370
|
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const toRect = rectIn(rhost, dest);
|
|
271
|
-
sizeStage(toRect.y + toRect.h);
|
|
371
|
+
const oldRect = rectIn(rhost, oldEl);
|
|
372
|
+
const newRect = rectIn(rhost, newEl);
|
|
373
|
+
sizeStage(Math.max(oldRect.y + oldRect.h, newRect.y + newRect.h));
|
|
272
374
|
|
|
273
375
|
const [oldImage, newImage] = await Promise.all([
|
|
274
|
-
capture(
|
|
275
|
-
capture(
|
|
376
|
+
capture(oldEl, oldRect.w, oldRect.h),
|
|
377
|
+
capture(newEl, newRect.w, newRect.h),
|
|
276
378
|
]);
|
|
277
379
|
|
|
278
|
-
pipe = applyRasterPipeline(rhost, chainFor(
|
|
380
|
+
pipe = applyRasterPipeline(rhost, chainFor(edge.effect), {
|
|
279
381
|
transition: {
|
|
280
382
|
oldImage, newImage,
|
|
281
|
-
oldRect
|
|
282
|
-
newRect
|
|
383
|
+
oldRect, oldTo: newRect,
|
|
384
|
+
newRect, newFrom: oldRect,
|
|
283
385
|
// The morph owns the screen strictly BETWEEN its ends: the
|
|
284
386
|
// real source presents at t=0, the real destination at t=1.
|
|
285
387
|
standDownAtStart: true,
|
|
286
388
|
// Opt back into the HTML-in-Canvas backend. Off by default
|
|
287
389
|
// for a morph: it moves the content INTO the canvas, where
|
|
288
390
|
// it becomes fallback content and stops being hit-testable.
|
|
289
|
-
live:
|
|
391
|
+
live: edge.live === true,
|
|
290
392
|
// Per-pixel choice rather than a uniform mix. A crossfade
|
|
291
393
|
// shows 50% of each at the midpoint, which reads as two
|
|
292
394
|
// states at once rather than one becoming the other.
|
|
293
|
-
fade:
|
|
395
|
+
fade: edge.fade || "morph",
|
|
294
396
|
},
|
|
295
397
|
});
|
|
296
398
|
|
|
297
399
|
if (tl) tl.destroy();
|
|
298
|
-
tl = progressTimeline(pipe, {
|
|
400
|
+
tl = progressTimeline(pipe, {
|
|
401
|
+
duration: edge.duration != null ? edge.duration : 900,
|
|
402
|
+
});
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Transition from wherever the graph is now to `destId`.
|
|
407
|
+
*
|
|
408
|
+
* Keyed by state id rather than by index: an index is only meaningful
|
|
409
|
+
* relative to one edge's destination list, and the controller has to
|
|
410
|
+
* answer "go here" from whichever state happens to be current.
|
|
411
|
+
*/
|
|
412
|
+
const goToState = async (destId) => {
|
|
413
|
+
const edge = edgeFrom(current);
|
|
414
|
+
if (!edge || !edge.toIds.includes(destId)) return;
|
|
415
|
+
const dest = states.get(destId);
|
|
416
|
+
if (!dest) return;
|
|
417
|
+
const srcEl = currentEl();
|
|
418
|
+
active = edge.toIds.indexOf(destId);
|
|
419
|
+
lastFrom = current;
|
|
420
|
+
|
|
421
|
+
for (const p of activeRasterPipelines()) {
|
|
422
|
+
if (stage.contains(p.canvas)) p.destroy();
|
|
423
|
+
}
|
|
424
|
+
// Clear everything EXCEPT the state being transitioned from.
|
|
425
|
+
// Emptying the host outright is what the single hop did, and it
|
|
426
|
+
// was safe only because the source lived in the live layer. From
|
|
427
|
+
// the second hop onward the source is a landed state sitting in
|
|
428
|
+
// this host, and emptying would delete the very element about to
|
|
429
|
+
// be captured. For a single hop the source is still in the live
|
|
430
|
+
// layer, so this removes exactly what it always did.
|
|
431
|
+
for (const child of [...rhost.children]) {
|
|
432
|
+
if (child !== srcEl) child.remove();
|
|
433
|
+
}
|
|
434
|
+
rhost.appendChild(dest);
|
|
435
|
+
outgoing = srcEl;
|
|
436
|
+
incoming = dest;
|
|
437
|
+
// Hidden from the moment it enters the document. Between insertion
|
|
438
|
+
// and applyRasterPipeline there are two awaited captures, and a
|
|
439
|
+
// destination that is merely in the DOM paints for those frames —
|
|
440
|
+
// which is the destination flashing before the morph runs.
|
|
441
|
+
// visibility (not display) so it still lays out and can be measured.
|
|
442
|
+
dest.style.visibility = "hidden";
|
|
443
|
+
|
|
444
|
+
// Place the destination before anything is measured: in a chain
|
|
445
|
+
// it takes the source's own position, so views replace each other
|
|
446
|
+
// in one region rather than marching down the page.
|
|
447
|
+
const fromRect = rectIn(rhost, srcEl);
|
|
448
|
+
Object.assign(dest.style, { position: "absolute", left: "0",
|
|
449
|
+
top: Math.round(inPlace ? fromRect.y : fromRect.h + 20) + "px",
|
|
450
|
+
boxSizing: "border-box" });
|
|
451
|
+
|
|
452
|
+
await buildTransition(srcEl, dest, edge);
|
|
453
|
+
if (!tl) return;
|
|
454
|
+
pipeOld = lastFrom; pipeNew = destId;
|
|
299
455
|
tl.set(0);
|
|
300
456
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
457
|
+
// Wire the destination BEFORE the timeline runs, not on arrival.
|
|
458
|
+
// Progress reaches 1 a frame before `to(1)`'s promise resolves, so
|
|
459
|
+
// wiring at landing leaves a window in which the destination is
|
|
460
|
+
// fully presented and its controls are dead — which a caller
|
|
461
|
+
// polling progress hits every time.
|
|
462
|
+
//
|
|
463
|
+
// Order still matters: forward triggers first, then back from
|
|
464
|
+
// whatever is left, so the roles cannot swap.
|
|
465
|
+
wireState(destId);
|
|
466
|
+
if (edge.back) claimBack(dest);
|
|
467
|
+
|
|
468
|
+
// `to()` resolves with the progress actually reached, and resolves
|
|
469
|
+
// rather than hanging when something interrupts it. So the
|
|
470
|
+
// resolved value IS the answer to "did we arrive?" — anything
|
|
471
|
+
// short of 1 means another transition took over, and a morph that
|
|
472
|
+
// did not arrive must not move the graph.
|
|
473
|
+
const reached = await tl.to(1);
|
|
474
|
+
if (reached === 1 && incoming === dest) land(destId, srcEl, edge);
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Unwind one step of the path actually taken.
|
|
479
|
+
*
|
|
480
|
+
* `back` is history, not an edge. The graph may offer a route from
|
|
481
|
+
* here to somewhere else entirely — `aurora` has a forward edge to
|
|
482
|
+
* `contact` — but going back means returning the way you came, which
|
|
483
|
+
* is what every user already expects from a browser. So the stack of
|
|
484
|
+
* landed states decides the destination, not the edge list.
|
|
485
|
+
*/
|
|
486
|
+
let backing = false;
|
|
487
|
+
let queuedBacks = 0;
|
|
488
|
+
|
|
489
|
+
/** Unwind exactly one step of the path taken. */
|
|
490
|
+
const stepBack = async () => {
|
|
491
|
+
if (!history.length) return;
|
|
492
|
+
const targetId = history[history.length - 1];
|
|
493
|
+
const targetEl = states.get(targetId);
|
|
494
|
+
if (!targetEl) return;
|
|
495
|
+
const fromEl = currentEl();
|
|
496
|
+
|
|
497
|
+
// The live pipeline is already the right pair when the last thing
|
|
498
|
+
// that ran was the hop being unwound. Reusing it is what makes an
|
|
499
|
+
// immediate back feel instant, and it is also what lets a back
|
|
500
|
+
// INTERRUPT a still-running forward — the timeline simply
|
|
501
|
+
// reverses from wherever it is.
|
|
502
|
+
if (pipe && tl && pipeOld === targetId && pipeNew === current) {
|
|
503
|
+
const reached = await tl.to(0);
|
|
504
|
+
if (reached === 0) landBack(targetId);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
await reverseRebuild(targetId, targetEl, fromEl);
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const goBack = async () => {
|
|
511
|
+
// Presses arriving mid-reversal are QUEUED, not dropped and not
|
|
512
|
+
// run concurrently. Dropping them makes a user hammering back sit
|
|
513
|
+
// one level down and look stuck; running them at once re-targets
|
|
514
|
+
// the hop that just finished, because `current` updates a tick
|
|
515
|
+
// after progress reaches its endpoint. Queuing gives the only
|
|
516
|
+
// behaviour that matches the control: one press, one level.
|
|
517
|
+
if (backing) { queuedBacks++; return; }
|
|
518
|
+
backing = true;
|
|
519
|
+
try {
|
|
520
|
+
await stepBack();
|
|
521
|
+
while (queuedBacks > 0 && history.length) {
|
|
522
|
+
queuedBacks--;
|
|
523
|
+
await stepBack();
|
|
306
524
|
}
|
|
525
|
+
} finally { backing = false; queuedBacks = 0; }
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
/** The reversal that cannot reuse the live pipeline: build it. */
|
|
529
|
+
const reverseRebuild = async (targetId, targetEl, fromEl) => {
|
|
530
|
+
|
|
531
|
+
// The old pipeline is kept ALIVE across the measuring phase and
|
|
532
|
+
// retired only once its replacement exists. It is what covers the
|
|
533
|
+
// stage while the state being returned to is revealed underneath
|
|
534
|
+
// it, and that ordering is what lets the reveal use `display`
|
|
535
|
+
// alone — no `visibility: hidden` anywhere.
|
|
536
|
+
//
|
|
537
|
+
// That matters beyond flicker. HTML-in-Canvas uploads an element
|
|
538
|
+
// through its PAINT RECORD, and a hidden element has none:
|
|
539
|
+
// `texElementImage2D` then throws "No cached paint record for
|
|
540
|
+
// element" and the pipeline falls back to snapshot mid-flight.
|
|
541
|
+
// Measuring something while hiding it is not an option on the
|
|
542
|
+
// live backend — it has to be painted, and merely covered.
|
|
543
|
+
//
|
|
544
|
+
// Retiring it too early is the other failure: destroying it
|
|
545
|
+
// before the replacement exists leaves a gap with nothing
|
|
546
|
+
// presenting, and never retiring it leaves its canvas stacked in
|
|
547
|
+
// the host showing a stale frame over the real transition.
|
|
548
|
+
const stale = [...activeRasterPipelines()].filter(
|
|
549
|
+
(p) => stage.contains(p.canvas));
|
|
550
|
+
|
|
551
|
+
const edge = edgeFrom(targetId) || {};
|
|
552
|
+
if (targetEl === rootEl) {
|
|
553
|
+
// Revealed, not shown: it needs a box to be measured and a
|
|
554
|
+
// paint record to be uploaded. The stale canvas above covers
|
|
555
|
+
// it until the transition takes over.
|
|
556
|
+
measuring = true;
|
|
557
|
+
live.style.display = "";
|
|
558
|
+
} else if (targetEl.parentNode !== rhost) {
|
|
559
|
+
// A later hop swept it out of the host; it has to be back in
|
|
560
|
+
// the document to be measured and captured.
|
|
561
|
+
targetEl.style.visibility = "hidden";
|
|
562
|
+
rhost.appendChild(targetEl);
|
|
563
|
+
const cur = rectIn(rhost, fromEl);
|
|
564
|
+
Object.assign(targetEl.style, {
|
|
565
|
+
position: "absolute", left: "0",
|
|
566
|
+
top: Math.round(inPlace ? cur.y : 0) + "px",
|
|
567
|
+
boxSizing: "border-box",
|
|
568
|
+
});
|
|
307
569
|
}
|
|
308
|
-
|
|
570
|
+
|
|
571
|
+
await buildTransition(targetEl, fromEl, edge);
|
|
572
|
+
for (const p of stale) p.destroy();
|
|
573
|
+
measuring = false;
|
|
574
|
+
if (!tl) return;
|
|
575
|
+
pipeOld = targetId; pipeNew = current;
|
|
576
|
+
outgoing = targetEl;
|
|
577
|
+
incoming = fromEl;
|
|
578
|
+
tl.set(1);
|
|
579
|
+
const reached = await tl.to(0);
|
|
580
|
+
if (reached === 0) landBack(targetId);
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
/** Arriving back: pop the step being unwound and present it. */
|
|
584
|
+
const landBack = (targetId) => {
|
|
585
|
+
if (history[history.length - 1] === targetId) history.pop();
|
|
586
|
+
current = targetId;
|
|
587
|
+
lastFrom = history.length ? history[history.length - 1] : null;
|
|
588
|
+
wireState(targetId);
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* A transition completed: the destination is now where the graph is,
|
|
593
|
+
* and therefore now a source in its own right.
|
|
594
|
+
*/
|
|
595
|
+
const land = (id, previousEl, viaEdge) => {
|
|
596
|
+
// The step just completed becomes the top of the stack — this is
|
|
597
|
+
// what `back` unwinds, and why back returns the way you came
|
|
598
|
+
// rather than following some edge that happens to point earlier.
|
|
599
|
+
if (lastFrom != null && lastFrom !== id) history.push(lastFrom);
|
|
600
|
+
current = id;
|
|
601
|
+
// The state left behind STAYS in the host, hidden by the frame
|
|
602
|
+
// loop. Removing it here was wrong in both directions: going back
|
|
603
|
+
// has to present it again, and the shader's t=0 frame is a
|
|
604
|
+
// picture of it, so the real element must be there to take over.
|
|
605
|
+
// The next forward transition sweeps it out, because the
|
|
606
|
+
// selective clear keeps only the state it departs from.
|
|
607
|
+
outgoing = previousEl && previousEl !== rootEl ? previousEl : null;
|
|
608
|
+
// Idempotent re-wire. The destination was wired before the
|
|
609
|
+
// timeline ran; this covers anything its own subtree changed
|
|
610
|
+
// during the transition.
|
|
611
|
+
wireState(id);
|
|
612
|
+
if (viaEdge && viaEdge.back) claimBack(states.get(id));
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
/** The back control is a button the forward wiring did not claim. */
|
|
616
|
+
const claimBack = (el) => {
|
|
617
|
+
if (!el) return;
|
|
618
|
+
const btn = [...el.querySelectorAll("button")].find(
|
|
619
|
+
(b) => !b.dataset.nodMorphWired && !b.dataset.nodMorphBack);
|
|
620
|
+
if (!btn) return;
|
|
621
|
+
btn.dataset.nodMorphBack = "1";
|
|
622
|
+
btn.addEventListener("click", () => { goBack(); });
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
// Positional entry point, kept for the handle's published shape: the
|
|
626
|
+
// index is resolved against the CURRENT state's edge.
|
|
627
|
+
const goTo = (i) => {
|
|
628
|
+
const edge = edgeFrom(current);
|
|
629
|
+
return edge ? goToState(edge.toIds[i]) : undefined;
|
|
309
630
|
};
|
|
310
631
|
|
|
311
632
|
// Link i in the source goes to destination i. By POSITION, not label:
|
|
@@ -321,45 +642,75 @@ function setUpMorph(mount, m, resolve) {
|
|
|
321
642
|
* case a plain call-to-action button is. If the source is itself the
|
|
322
643
|
* control (a lone button), the source is the trigger.
|
|
323
644
|
*/
|
|
324
|
-
const triggersOf = () => {
|
|
325
|
-
const links = [...
|
|
645
|
+
const triggersOf = (el) => {
|
|
646
|
+
const links = [...el.querySelectorAll("a")];
|
|
326
647
|
if (links.length) return links;
|
|
327
|
-
const btns = [...
|
|
648
|
+
const btns = [...el.querySelectorAll("button, [role=button]")];
|
|
328
649
|
if (btns.length) return btns;
|
|
329
|
-
return [
|
|
650
|
+
return [el];
|
|
330
651
|
};
|
|
331
652
|
|
|
332
|
-
|
|
653
|
+
/**
|
|
654
|
+
* Wire one trigger against the edge leaving its state.
|
|
655
|
+
*
|
|
656
|
+
* `byName` says the edge's labels actually match something in this
|
|
657
|
+
* state, and it decides which rule applies. The single-destination
|
|
658
|
+
* shortcut — any trigger goes to the only destination — is right for
|
|
659
|
+
* a lone call-to-action button whose text matches no declared label,
|
|
660
|
+
* and wrong for a card that has both a forward control and a back
|
|
661
|
+
* control: there, "any trigger" sweeps up the back button too. When
|
|
662
|
+
* the labels match, the labels rule.
|
|
663
|
+
*/
|
|
664
|
+
const wire = (a, edge, triggers, byName) => {
|
|
333
665
|
if (a.dataset.nodMorphWired) return;
|
|
334
|
-
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
666
|
+
// The back control is claimed first and is never also a forward
|
|
667
|
+
// trigger. The two wirings guard on independent flags, so a state
|
|
668
|
+
// whose only controls are buttons — `triggersOf` falls through to
|
|
669
|
+
// buttons — and whose edge has a single destination — any trigger
|
|
670
|
+
// goes there — would otherwise fire BOTH on one click.
|
|
671
|
+
if (a.dataset.nodMorphBack) return;
|
|
672
|
+
const destId = byName ? edge.labelToId.get(norm(a.textContent))
|
|
673
|
+
// One destination and no matching label: nothing to
|
|
674
|
+
// disambiguate, so any trigger goes there.
|
|
675
|
+
: edge.toIds.length === 1 ? edge.toIds[0]
|
|
676
|
+
: edge.byLabel ? edge.labelToId.get(norm(a.textContent))
|
|
677
|
+
: edge.toIds[triggers.indexOf(a)];
|
|
678
|
+
if (!destId || !states.has(destId)) return;
|
|
342
679
|
a.dataset.nodMorphWired = "1";
|
|
343
|
-
a.dataset.nodMorphTo =
|
|
680
|
+
a.dataset.nodMorphTo = destId;
|
|
344
681
|
a.addEventListener("click", (e) => {
|
|
345
682
|
// Nothing resolved -> the link keeps its own behaviour, which
|
|
346
683
|
// on an ordinary page is real navigation.
|
|
347
|
-
if (!
|
|
684
|
+
if (!states.has(destId)) return;
|
|
348
685
|
e.preventDefault();
|
|
349
|
-
|
|
686
|
+
goToState(destId);
|
|
350
687
|
});
|
|
351
688
|
};
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
689
|
+
|
|
690
|
+
/** Wire every trigger of a state, if that state has an outgoing edge. */
|
|
691
|
+
const observed = new Set();
|
|
692
|
+
const wireState = (id) => {
|
|
693
|
+
const edge = edgeFrom(id);
|
|
694
|
+
const el = states.get(id);
|
|
695
|
+
if (!edge || !el) return;
|
|
696
|
+
const triggers = triggersOf(el);
|
|
697
|
+
const byName = !!edge.byLabel &&
|
|
698
|
+
triggers.some((t) => edge.labelToId.has(norm(t.textContent)));
|
|
699
|
+
triggers.forEach((t) => wire(t, edge, triggers, byName));
|
|
700
|
+
|
|
701
|
+
// A responsive nav REPLACES its links when it crosses a
|
|
702
|
+
// breakpoint — the Switcher swaps the whole view — so anything
|
|
703
|
+
// wired once is gone after a resize. Re-wire whatever appears;
|
|
704
|
+
// `nodMorphWired` keeps it idempotent. Every state that can be a
|
|
705
|
+
// source gets this, not just the root, because a chain can be
|
|
706
|
+
// resized while three levels deep.
|
|
707
|
+
if (typeof MutationObserver !== "undefined" && !observed.has(id)) {
|
|
708
|
+
observed.add(id);
|
|
709
|
+
new MutationObserver(() => wireState(id))
|
|
710
|
+
.observe(el, { childList: true, subtree: true });
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
wireState(rootId);
|
|
363
714
|
|
|
364
715
|
// Only the source is on screen until something is chosen.
|
|
365
716
|
const frame = () => {
|
|
@@ -403,22 +754,42 @@ function setUpMorph(mount, m, resolve) {
|
|
|
403
754
|
// canvas alone showed BOTH ends at t=1, because at that point the
|
|
404
755
|
// canvas is down and nothing distinguished the two.
|
|
405
756
|
const handover = !pipe || !canvasUp;
|
|
406
|
-
|
|
757
|
+
// The live layer holds the ROOT state only. Landed states live in
|
|
758
|
+
// the host, so from the second hop onward "t is at zero" does not
|
|
759
|
+
// mean the root should be showing — `current` decides that.
|
|
760
|
+
const wantLive = measuring ? ""
|
|
761
|
+
: handover && t <= 0 && (current === rootId || outgoing === rootEl)
|
|
762
|
+
? "" : "none";
|
|
407
763
|
if (live.style.display !== wantLive) live.style.display = wantLive;
|
|
408
|
-
|
|
764
|
+
// By identity, not by position: during a chain hop the outgoing
|
|
765
|
+
// state is also a child of this host, so `firstElementChild`
|
|
766
|
+
// would sometimes name the wrong one of the two.
|
|
767
|
+
const dest = incoming || rhost.firstElementChild;
|
|
409
768
|
if (dest && dest.style) {
|
|
410
769
|
const want = pipe && handover && t >= 1 ? "" : "hidden";
|
|
411
770
|
if (dest.style.visibility !== want) dest.style.visibility = want;
|
|
412
771
|
}
|
|
772
|
+
// The state being left behind is the other end of the same rule:
|
|
773
|
+
// it presents at t=0, and only while the canvas has stood down.
|
|
774
|
+
// The root is exempt because the live layer's display already
|
|
775
|
+
// governs it, and driving both would fight.
|
|
776
|
+
if (outgoing && outgoing !== rootEl && outgoing.style) {
|
|
777
|
+
const want = handover && t <= 0 ? "" : "hidden";
|
|
778
|
+
if (outgoing.style.visibility !== want) outgoing.style.visibility = want;
|
|
779
|
+
}
|
|
413
780
|
requestAnimationFrame(frame);
|
|
414
781
|
};
|
|
415
782
|
requestAnimationFrame(frame);
|
|
416
783
|
|
|
417
784
|
return {
|
|
418
|
-
stage, from:
|
|
785
|
+
stage, from: rootEl,
|
|
786
|
+
get destinations() { return rootEdge.toIds.map((id) => states.get(id) || null); },
|
|
787
|
+
get states() { return states; },
|
|
788
|
+
get current() { return current; },
|
|
789
|
+
get history() { return [...history]; },
|
|
419
790
|
get progress() { return pipe ? pipe.progress : 0; },
|
|
420
791
|
get active() { return active; },
|
|
421
|
-
goTo,
|
|
792
|
+
goTo, goToState,
|
|
422
793
|
back() { if (tl) tl.to(0); },
|
|
423
794
|
destroy() {
|
|
424
795
|
if (tl) tl.destroy();
|