nodality 1.0.221 → 1.1.0
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/API.md +227 -0
- package/bin/nodality.js +64 -0
- package/dist/animator.cjs.js +1 -1
- package/dist/animator.cjs.js.LICENSE.txt +1 -1
- package/dist/animator.esm.js +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 +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 +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 +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 +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 +131 -1
- package/dist/button.cjs.js +1 -1
- package/dist/button.cjs.js.LICENSE.txt +1 -1
- package/dist/button.esm.js +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 +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 +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 +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 +1 -1
- package/dist/container.esm.js.LICENSE.txt +1 -1
- package/dist/data-list.cjs.js +1 -1
- package/dist/data-list.cjs.js.LICENSE.txt +1 -1
- package/dist/data-list.esm.js +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 +97 -1
- package/dist/designer.esm.js +1 -1
- package/dist/designer.esm.js.LICENSE.txt +97 -1
- package/dist/element-mapper.cjs.js +1 -1
- package/dist/element-mapper.cjs.js.LICENSE.txt +17 -1
- package/dist/element-mapper.esm.js +1 -1
- package/dist/element-mapper.esm.js.LICENSE.txt +17 -1
- package/dist/finalresult.esm.js +1 -1
- package/dist/finalresult.esm.js.LICENSE.txt +158 -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 +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 +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 +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 +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 +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 +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 +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 +158 -1
- package/dist/index.d.ts +962 -0
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.LICENSE.txt +158 -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 +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 +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 +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 +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 +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 +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 +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 +1 -1
- package/dist/progress.esm.js.LICENSE.txt +1 -1
- package/dist/radio.cjs.js +1 -1
- package/dist/radio.cjs.js.LICENSE.txt +1 -1
- package/dist/radio.esm.js +1 -1
- package/dist/radio.esm.js.LICENSE.txt +1 -1
- package/dist/range.cjs.js +1 -1
- package/dist/range.cjs.js.LICENSE.txt +1 -1
- package/dist/range.esm.js +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +1 -1
- package/dist/zoom-card.esm.js.LICENSE.txt +1 -1
- package/examples/custom-raster-op.js +174 -0
- package/layout/animator.js +30 -2
- 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 +3 -2
- package/layout/button.js +1 -1
- package/layout/center.js +1 -1
- package/layout/checkbox.js +2 -2
- package/layout/circle.js +3 -2
- package/layout/code.js +3 -2
- package/layout/container.js +3 -4
- 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 +3 -2
- package/layout/form-components/custom.js +1 -1
- package/layout/form-components/data-list.js +2 -2
- package/layout/form-components/floating-input.js +2 -2
- package/layout/form-components/form-all.js +2 -2
- package/layout/form-components/form.js +1 -1
- package/layout/form-components/image-picker.js +1 -1
- package/layout/form-components/picker.js +2 -2
- package/layout/form-components/radio.js +2 -2
- package/layout/form-components/radiogroup.js +2 -2
- package/layout/form-components/range.js +2 -2
- package/layout/free.js +3 -4
- 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 +5 -5
- package/layout/list.js +3 -4
- package/layout/meta-adder.js +1 -1
- package/layout/modal-2025.js +1 -1
- package/layout/morph.js +1003 -0
- package/layout/multiswitcher.js +11 -3
- package/layout/nav-bar.js +2 -3
- package/layout/nav-factor/custom-div.js +2 -2
- package/layout/new-nav-bar.js +11 -4
- package/layout/polygon.js +3 -4
- 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 +3 -4
- package/layout/switcher.js +1 -1
- package/layout/table.js +1 -1
- package/layout/text-field.js +1 -1
- package/layout/text.js +5 -5
- 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 +62 -0
- package/lib/data.js +37 -1
- package/lib/designer.js +18 -1
- package/lib/element-mapper.js +351 -247
- package/lib/keyframe-animation.js +1 -1
- package/lib/link-getter.js +1 -1
- package/lib/morph-node.js +424 -0
- package/lib/raster-inspect.js +409 -0
- package/lib/raster-ops.js +1980 -81
- package/lib/raster-presets.js +329 -0
- package/lib/scroll-video.js +1 -1
- package/lib/seo.js +28 -1
- package/lib/stacker.js +1 -1
- package/lib/suggest.js +66 -0
- package/lib/theme.js +1 -1
- package/lib/transform-anim.js +1 -1
- package/lib/transition.js +205 -0
- package/package.json +20 -7
package/lib/link-getter.js
CHANGED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* nodality v1.1.0
|
|
3
|
+
* (c) 2026 Filip Vabrousek
|
|
4
|
+
* License: MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/*!
|
|
8
|
+
* morph-node.js — `{ op: "morph" }`, the (E,N) form of a transition.
|
|
9
|
+
*
|
|
10
|
+
* Everything else in N describes how an element LOOKS. A morph node
|
|
11
|
+
* describes which element BECOMES which, and on what interaction:
|
|
12
|
+
*
|
|
13
|
+
* { op: "morph", from: "topnav", to: ["work", "about", "contact"],
|
|
14
|
+
* effect: "t-vhs", duration: 900, back: true }
|
|
15
|
+
*
|
|
16
|
+
* Before this, a transition was the one thing the two arrays could not
|
|
17
|
+
* express. A page wanting a morph had to capture both sides itself,
|
|
18
|
+
* measure two rects, call applyRasterPipeline with a `transition` block,
|
|
19
|
+
* drive `t` through a timeline, and wire the clicks — a few hundred lines
|
|
20
|
+
* sitting *around* the arrays rather than in them.
|
|
21
|
+
*
|
|
22
|
+
* What this owns, so a caller does not have to:
|
|
23
|
+
*
|
|
24
|
+
* - the destinations start hidden, so only the source is on screen
|
|
25
|
+
* - link i in the source maps to destination i
|
|
26
|
+
* - both sides are captured as stills and travel one converging box
|
|
27
|
+
* - `back: true` wires the destination's own button to return
|
|
28
|
+
*
|
|
29
|
+
* Runs AFTER Des has mounted, because it needs rendered geometry: rects
|
|
30
|
+
* come from real layout, not from the descriptors.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { applyRasterPipeline, activeRasterPipelines } from "./raster-ops.js";
|
|
34
|
+
import { preset } from "./raster-presets.js";
|
|
35
|
+
import { progressTimeline } from "./transition.js";
|
|
36
|
+
|
|
37
|
+
/** An element's box in its host's coordinates — what a transition wants. */
|
|
38
|
+
const rectIn = (host, el) => {
|
|
39
|
+
const h = host.getBoundingClientRect();
|
|
40
|
+
const r = el.getBoundingClientRect();
|
|
41
|
+
return { x: r.x - h.x, y: r.y - h.y, w: r.width, h: r.height };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Freeze a subtree into an Image.
|
|
46
|
+
*
|
|
47
|
+
* Two things a naive foreignObject capture gets wrong, both of which show
|
|
48
|
+
* up as a jump at the handover:
|
|
49
|
+
*
|
|
50
|
+
* - RESOLUTION. Asking for w x h rasterises at CSS density, so on a 2x
|
|
51
|
+
* display the still is half the density of the box it is drawn into
|
|
52
|
+
* and reads soft next to real text. The viewBox stays in CSS units;
|
|
53
|
+
* only the raster grid grows.
|
|
54
|
+
* - VIEWPORT UNITS. Nodality sizes text with calc(1.375rem + 3.525vw).
|
|
55
|
+
* Inside a foreignObject, vw resolves against the SVG's OWN viewport,
|
|
56
|
+
* not the page — so the same heading renders ~33px in a 300px capture
|
|
57
|
+
* and ~89px on a wide window. Computed type is copied onto the clone
|
|
58
|
+
* so the still matches the layout it replaces.
|
|
59
|
+
*/
|
|
60
|
+
async function capture(el, w, h) {
|
|
61
|
+
const clone = el.cloneNode(true);
|
|
62
|
+
clone.style.position = "static";
|
|
63
|
+
clone.style.left = clone.style.top = "0";
|
|
64
|
+
clone.style.margin = "0";
|
|
65
|
+
// The element being captured is kept visibility:hidden so it cannot
|
|
66
|
+
// flash before the pipeline owns it (see goTo). It still lays out, so
|
|
67
|
+
// rects and serialisation are correct — but the clone has to paint.
|
|
68
|
+
clone.style.visibility = "visible";
|
|
69
|
+
|
|
70
|
+
const src = [el, ...el.querySelectorAll("*")];
|
|
71
|
+
const dst = [clone, ...clone.querySelectorAll("*")];
|
|
72
|
+
for (let i = 0; i < src.length; i++) {
|
|
73
|
+
const cs = getComputedStyle(src[i]);
|
|
74
|
+
dst[i].style.fontSize = cs.fontSize;
|
|
75
|
+
dst[i].style.lineHeight = cs.lineHeight;
|
|
76
|
+
dst[i].style.fontWeight = cs.fontWeight;
|
|
77
|
+
dst[i].style.fontFamily = cs.fontFamily;
|
|
78
|
+
dst[i].style.letterSpacing = cs.letterSpacing;
|
|
79
|
+
// BOX metrics too, not just type. `width: min(560px, 92vw)`
|
|
80
|
+
// re-resolves inside the foreignObject against the SVG's viewport
|
|
81
|
+
// rather than the page, so a card measured at 307px lays out at
|
|
82
|
+
// 282px in the capture — and that narrower layout is then
|
|
83
|
+
// stretched back into the 307px box. The BOX matches, so it
|
|
84
|
+
// measures clean; the CONTENT inside is visibly scaled up, and
|
|
85
|
+
// swapping it for the real element at t=1 is a pop.
|
|
86
|
+
dst[i].style.width = cs.width;
|
|
87
|
+
dst[i].style.height = cs.height;
|
|
88
|
+
dst[i].style.padding = cs.padding;
|
|
89
|
+
dst[i].style.boxSizing = "content-box";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const R = Math.max(1, Math.min(
|
|
93
|
+
(typeof window !== "undefined" && window.devicePixelRatio) || 1, 3));
|
|
94
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w * R}" ` +
|
|
95
|
+
`height="${h * R}" viewBox="0 0 ${w} ${h}">` +
|
|
96
|
+
`<foreignObject width="100%" height="100%">` +
|
|
97
|
+
`<div xmlns="http://www.w3.org/1999/xhtml">` +
|
|
98
|
+
new XMLSerializer().serializeToString(clone) +
|
|
99
|
+
`</div></foreignObject></svg>`;
|
|
100
|
+
const img = new Image();
|
|
101
|
+
await new Promise((res, rej) => {
|
|
102
|
+
img.onload = res;
|
|
103
|
+
img.onerror = rej;
|
|
104
|
+
img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
|
|
105
|
+
});
|
|
106
|
+
return img;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const chainFor = (effect) => {
|
|
110
|
+
if (!effect) return [];
|
|
111
|
+
if (Array.isArray(effect)) return effect; // an inline node array
|
|
112
|
+
try { return preset(effect) || []; } catch (e) { return []; }
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {Element} mount the container Des rendered into
|
|
117
|
+
* @param {object[]} elements the E array, in order
|
|
118
|
+
* @param {object[]} nodes the N array
|
|
119
|
+
* @returns {object[]} one handle per morph node, for tests and teardown
|
|
120
|
+
*/
|
|
121
|
+
function applyMorphNodes(mount, elements, nodes) {
|
|
122
|
+
if (!mount || typeof document === "undefined") return [];
|
|
123
|
+
const morphs = (nodes || []).filter((n) => n && n.op === "morph");
|
|
124
|
+
if (!morphs.length) return [];
|
|
125
|
+
|
|
126
|
+
// Pair descriptors with rendered nodes BY POSITION. Some components
|
|
127
|
+
// carry their `id` through to the DOM (wrap does) and some do not
|
|
128
|
+
// (protoNav drops it), so an id lookup alone silently misses half the
|
|
129
|
+
// page. Position is what Des actually guarantees.
|
|
130
|
+
const rendered = [...mount.children];
|
|
131
|
+
const resolve = (id) => {
|
|
132
|
+
const byId = document.getElementById(id);
|
|
133
|
+
if (byId && mount.contains(byId)) return byId;
|
|
134
|
+
const i = (elements || []).findIndex((e) => e && e.id === id);
|
|
135
|
+
return i >= 0 ? rendered[i] : null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return morphs.map((m) => setUpMorph(mount, m, resolve)).filter(Boolean);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function setUpMorph(mount, m, resolve) {
|
|
142
|
+
const fromEl = resolve(m.from);
|
|
143
|
+
|
|
144
|
+
// `to` takes two forms, and the object form is the one that survives
|
|
145
|
+
// a real navbar:
|
|
146
|
+
//
|
|
147
|
+
// ["work", "about"] by POSITION — link i goes to i
|
|
148
|
+
// { Work: "work", About: "about" } by LABEL — link text decides
|
|
149
|
+
//
|
|
150
|
+
// Position looks tidier and is wrong for anything but a toy nav. A
|
|
151
|
+
// responsive bar renders a DIFFERENT set of links per breakpoint
|
|
152
|
+
// (About/Services/Contact wide, About/About/Contact collapsed), so
|
|
153
|
+
// index i is not the same destination at two window sizes. And a nav
|
|
154
|
+
// that repeats a label sends two links to one arbitrary place. Labels
|
|
155
|
+
// are what the user is actually reading and clicking.
|
|
156
|
+
const byLabel = m.to && !Array.isArray(m.to) && typeof m.to === "object"
|
|
157
|
+
? m.to : null;
|
|
158
|
+
const toIds = byLabel
|
|
159
|
+
? [...new Set(Object.values(m.to))]
|
|
160
|
+
: (Array.isArray(m.to) ? m.to : [m.to]);
|
|
161
|
+
const toEls = toIds.map(resolve);
|
|
162
|
+
if (!fromEl || !toEls.some(Boolean)) {
|
|
163
|
+
console.warn("[nodality] morph: could not resolve", m.from, "->", toIds);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
const norm = (t) => String(t || "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
167
|
+
const labelToIndex = new Map();
|
|
168
|
+
if (byLabel) {
|
|
169
|
+
for (const [label, id] of Object.entries(m.to)) {
|
|
170
|
+
const i = toIds.indexOf(id);
|
|
171
|
+
if (i >= 0) labelToIndex.set(norm(label), i);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// A positioned stage, standing where the source stood. The source and
|
|
176
|
+
// the destinations are siblings inside it rather than in page flow,
|
|
177
|
+
// because a morph interpolates a BOX and both ends need a common
|
|
178
|
+
// coordinate space to be measured in.
|
|
179
|
+
const stage = document.createElement("div");
|
|
180
|
+
stage.className = "nod-morph";
|
|
181
|
+
stage.style.cssText = "position:relative;";
|
|
182
|
+
mount.insertBefore(stage, fromEl);
|
|
183
|
+
|
|
184
|
+
const rhost = document.createElement("div");
|
|
185
|
+
rhost.className = "nod-morph-host";
|
|
186
|
+
rhost.style.cssText = "position:absolute;inset:0;";
|
|
187
|
+
// The source stays OUTSIDE the raster host on purpose: the pipeline
|
|
188
|
+
// suppresses its host's ink while t is in (0,1), and the source must
|
|
189
|
+
// stay clickable and selectable at rest.
|
|
190
|
+
const live = document.createElement("div");
|
|
191
|
+
live.className = "nod-morph-live";
|
|
192
|
+
live.style.cssText = "position:absolute;inset:0;";
|
|
193
|
+
stage.appendChild(rhost);
|
|
194
|
+
stage.appendChild(live);
|
|
195
|
+
|
|
196
|
+
live.appendChild(fromEl);
|
|
197
|
+
fromEl.style.position = "absolute";
|
|
198
|
+
fromEl.style.left = fromEl.style.top = "0";
|
|
199
|
+
|
|
200
|
+
// Destinations start OFF the page. Without this every subview renders
|
|
201
|
+
// stacked under the nav, which is the whole "why is everything
|
|
202
|
+
// visible at once" problem.
|
|
203
|
+
const templates = toEls.map((el) => {
|
|
204
|
+
if (!el) return null;
|
|
205
|
+
el.remove();
|
|
206
|
+
return el;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const sizeStage = (h) => {
|
|
210
|
+
stage.style.minHeight =
|
|
211
|
+
Math.max(fromEl.getBoundingClientRect().height, h || 0) + 24 + "px";
|
|
212
|
+
};
|
|
213
|
+
sizeStage(0);
|
|
214
|
+
|
|
215
|
+
let pipe = null;
|
|
216
|
+
let tl = null;
|
|
217
|
+
let active = -1;
|
|
218
|
+
|
|
219
|
+
const goTo = async (i) => {
|
|
220
|
+
const dest = templates[i];
|
|
221
|
+
if (!dest) return;
|
|
222
|
+
active = i;
|
|
223
|
+
|
|
224
|
+
for (const p of activeRasterPipelines()) {
|
|
225
|
+
if (stage.contains(p.canvas)) p.destroy();
|
|
226
|
+
}
|
|
227
|
+
rhost.innerHTML = "";
|
|
228
|
+
rhost.appendChild(dest);
|
|
229
|
+
// Hidden from the moment it enters the document. Between insertion
|
|
230
|
+
// and applyRasterPipeline there are two awaited captures, and a
|
|
231
|
+
// destination that is merely in the DOM paints for those frames —
|
|
232
|
+
// which is the destination flashing before the morph runs.
|
|
233
|
+
// visibility (not display) so it still lays out and can be measured.
|
|
234
|
+
dest.style.visibility = "hidden";
|
|
235
|
+
|
|
236
|
+
// WAIT FOR IMAGES before measuring anything.
|
|
237
|
+
//
|
|
238
|
+
// An <img> has no intrinsic box until it decodes — even from a
|
|
239
|
+
// data: URL, which is async too. Measure before that and the
|
|
240
|
+
// destination's rect is its pre-image layout, so the shader draws
|
|
241
|
+
// the capture into a box that does not match where the real card
|
|
242
|
+
// ends up. The handover then pops between two different sizes,
|
|
243
|
+
// which is the flash at the end of the transition.
|
|
244
|
+
const imgs = [...dest.querySelectorAll("img")];
|
|
245
|
+
if (imgs.length) {
|
|
246
|
+
await Promise.all(imgs.map((im) => {
|
|
247
|
+
if (im.complete && im.naturalWidth) return null;
|
|
248
|
+
if (typeof im.decode === "function") return im.decode().catch(() => {});
|
|
249
|
+
return new Promise((res) => {
|
|
250
|
+
im.addEventListener("load", res, { once: true });
|
|
251
|
+
im.addEventListener("error", res, { once: true });
|
|
252
|
+
});
|
|
253
|
+
}));
|
|
254
|
+
// One more frame so layout reflects the decoded intrinsic size.
|
|
255
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const fromRect = rectIn(rhost, fromEl);
|
|
259
|
+
Object.assign(dest.style, { position: "absolute", left: "0",
|
|
260
|
+
top: Math.round(fromRect.h + 20) + "px", boxSizing: "border-box" });
|
|
261
|
+
const toRect = rectIn(rhost, dest);
|
|
262
|
+
sizeStage(toRect.y + toRect.h);
|
|
263
|
+
|
|
264
|
+
const [oldImage, newImage] = await Promise.all([
|
|
265
|
+
capture(fromEl, fromRect.w, fromRect.h),
|
|
266
|
+
capture(dest, toRect.w, toRect.h),
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
pipe = applyRasterPipeline(rhost, chainFor(m.effect), {
|
|
270
|
+
transition: {
|
|
271
|
+
oldImage, newImage,
|
|
272
|
+
oldRect: fromRect, oldTo: toRect,
|
|
273
|
+
newRect: toRect, newFrom: fromRect,
|
|
274
|
+
// The morph owns the screen strictly BETWEEN its ends: the
|
|
275
|
+
// real source presents at t=0, the real destination at t=1.
|
|
276
|
+
standDownAtStart: true,
|
|
277
|
+
// Opt back into the HTML-in-Canvas backend. Off by default
|
|
278
|
+
// for a morph: it moves the content INTO the canvas, where
|
|
279
|
+
// it becomes fallback content and stops being hit-testable.
|
|
280
|
+
live: m.live === true,
|
|
281
|
+
// Per-pixel choice rather than a uniform mix. A crossfade
|
|
282
|
+
// shows 50% of each at the midpoint, which reads as two
|
|
283
|
+
// states at once rather than one becoming the other.
|
|
284
|
+
fade: m.fade || "morph",
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (tl) tl.destroy();
|
|
289
|
+
tl = progressTimeline(pipe, { duration: m.duration != null ? m.duration : 900 });
|
|
290
|
+
tl.set(0);
|
|
291
|
+
|
|
292
|
+
if (m.back !== false) {
|
|
293
|
+
const btn = dest.querySelector("button");
|
|
294
|
+
if (btn && !btn.dataset.nodMorphBack) {
|
|
295
|
+
btn.dataset.nodMorphBack = "1";
|
|
296
|
+
btn.addEventListener("click", () => { if (tl) tl.to(0); });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
tl.to(1);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// Link i in the source goes to destination i. By POSITION, not label:
|
|
303
|
+
// a prototype nav can render two links reading the same thing, and
|
|
304
|
+
// matching on text would send both to one destination.
|
|
305
|
+
/**
|
|
306
|
+
* What in the source starts the morph.
|
|
307
|
+
*
|
|
308
|
+
* Anchors take PRIORITY, and that ordering is load-bearing rather
|
|
309
|
+
* than stylistic: a navbar's own hamburger is a <button>, and it
|
|
310
|
+
* DISCLOSES the menu — it must never navigate. So buttons are only
|
|
311
|
+
* considered when the source contains no links at all, which is the
|
|
312
|
+
* case a plain call-to-action button is. If the source is itself the
|
|
313
|
+
* control (a lone button), the source is the trigger.
|
|
314
|
+
*/
|
|
315
|
+
const triggersOf = () => {
|
|
316
|
+
const links = [...fromEl.querySelectorAll("a")];
|
|
317
|
+
if (links.length) return links;
|
|
318
|
+
const btns = [...fromEl.querySelectorAll("button, [role=button]")];
|
|
319
|
+
if (btns.length) return btns;
|
|
320
|
+
return [fromEl];
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const wire = (a) => {
|
|
324
|
+
if (a.dataset.nodMorphWired) return;
|
|
325
|
+
const triggers = triggersOf();
|
|
326
|
+
// One destination means there is nothing to disambiguate: any
|
|
327
|
+
// trigger goes there, whatever it is labelled. Only a fan-out
|
|
328
|
+
// needs a label map or an index.
|
|
329
|
+
const idx = toIds.length === 1 ? 0
|
|
330
|
+
: byLabel ? labelToIndex.get(norm(a.textContent))
|
|
331
|
+
: triggers.indexOf(a);
|
|
332
|
+
if (idx == null || idx < 0 || !templates[idx]) return;
|
|
333
|
+
a.dataset.nodMorphWired = "1";
|
|
334
|
+
a.dataset.nodMorphTo = toIds[idx];
|
|
335
|
+
a.addEventListener("click", (e) => {
|
|
336
|
+
// Nothing resolved -> the link keeps its own behaviour, which
|
|
337
|
+
// on an ordinary page is real navigation.
|
|
338
|
+
if (!templates[idx]) return;
|
|
339
|
+
e.preventDefault();
|
|
340
|
+
goTo(idx);
|
|
341
|
+
});
|
|
342
|
+
};
|
|
343
|
+
const wireAll = () => triggersOf().forEach(wire);
|
|
344
|
+
wireAll();
|
|
345
|
+
|
|
346
|
+
// A responsive nav REPLACES its links when it crosses a breakpoint —
|
|
347
|
+
// the Switcher swaps the whole view — so anything wired once is gone
|
|
348
|
+
// after a resize. Re-wire whatever appears; `nodMorphWired` keeps it
|
|
349
|
+
// idempotent.
|
|
350
|
+
if (typeof MutationObserver !== "undefined") {
|
|
351
|
+
new MutationObserver(wireAll).observe(fromEl,
|
|
352
|
+
{ childList: true, subtree: true });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Only the source is on screen until something is chosen.
|
|
356
|
+
const frame = () => {
|
|
357
|
+
if (!stage.isConnected) return;
|
|
358
|
+
// Who presents: the real source at t=0, the canvas in between, the
|
|
359
|
+
// real destination at t=1. Driving BOTH ends off progress means
|
|
360
|
+
// there is no moment where two of them are on screen together, and
|
|
361
|
+
// none where the destination appears before its morph has run.
|
|
362
|
+
const t = pipe ? pipe.progress : 0;
|
|
363
|
+
const cv = rhost.querySelector("canvas");
|
|
364
|
+
// Is the canvas ACTUALLY presenting right now? Not "should it be" —
|
|
365
|
+
// the pipeline owns that flag and re-asserts it from its own rAF
|
|
366
|
+
// loop, which is a different loop from this one.
|
|
367
|
+
//
|
|
368
|
+
// Both endpoints used to be computed from `t` alone, in parallel
|
|
369
|
+
// with the pipeline's decision. Whichever loop ran first that
|
|
370
|
+
// frame won, and when the pipeline hid the canvas before this loop
|
|
371
|
+
// revealed the DOM there was a single frame with NEITHER on
|
|
372
|
+
// screen — a flash at the end of the transition, in both
|
|
373
|
+
// directions.
|
|
374
|
+
//
|
|
375
|
+
// Keying off the canvas's real state makes the two states OVERLAP
|
|
376
|
+
// instead of gapping. Overlap is invisible: the shader's last
|
|
377
|
+
// frame is the destination and its first is the source, so a frame
|
|
378
|
+
// showing both is identical to a frame showing either. A frame
|
|
379
|
+
// showing neither is the background.
|
|
380
|
+
const canvasUp = !!cv && (cv.style.visibility || "visible") === "visible";
|
|
381
|
+
// Hand over only once the canvas has ACTUALLY stood down, and let
|
|
382
|
+
// `t` decide which end takes over. Both conditions are needed:
|
|
383
|
+
//
|
|
384
|
+
// canvas still up -> it is presenting; both DOM ends stay
|
|
385
|
+
// hidden, whatever t says
|
|
386
|
+
// canvas down -> the pipeline has finished its handover
|
|
387
|
+
// pass, which is also where it restores the
|
|
388
|
+
// host's ink. Revealing before that shows a
|
|
389
|
+
// real element whose colours are still
|
|
390
|
+
// suppressed — a transparent box for one
|
|
391
|
+
// frame, which is the flash.
|
|
392
|
+
//
|
|
393
|
+
// Gating on `t` alone raced the pipeline's own loop; gating on the
|
|
394
|
+
// canvas alone showed BOTH ends at t=1, because at that point the
|
|
395
|
+
// canvas is down and nothing distinguished the two.
|
|
396
|
+
const handover = !pipe || !canvasUp;
|
|
397
|
+
const wantLive = handover && t <= 0 ? "" : "none";
|
|
398
|
+
if (live.style.display !== wantLive) live.style.display = wantLive;
|
|
399
|
+
const dest = rhost.firstElementChild;
|
|
400
|
+
if (dest && dest.style) {
|
|
401
|
+
const want = pipe && handover && t >= 1 ? "" : "hidden";
|
|
402
|
+
if (dest.style.visibility !== want) dest.style.visibility = want;
|
|
403
|
+
}
|
|
404
|
+
requestAnimationFrame(frame);
|
|
405
|
+
};
|
|
406
|
+
requestAnimationFrame(frame);
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
stage, from: fromEl, destinations: templates,
|
|
410
|
+
get progress() { return pipe ? pipe.progress : 0; },
|
|
411
|
+
get active() { return active; },
|
|
412
|
+
goTo,
|
|
413
|
+
back() { if (tl) tl.to(0); },
|
|
414
|
+
destroy() {
|
|
415
|
+
if (tl) tl.destroy();
|
|
416
|
+
for (const p of activeRasterPipelines()) {
|
|
417
|
+
if (stage.contains(p.canvas)) p.destroy();
|
|
418
|
+
}
|
|
419
|
+
stage.remove();
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export { applyMorphNodes };
|