nodality 1.0.186 → 1.0.188
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/dist/animator.cjs.js +1 -1
- package/dist/animator.esm.js +1 -1
- package/dist/audionew.cjs.js +1 -1
- package/dist/audionew.esm.js +1 -1
- package/dist/base.cjs.js +1 -1
- package/dist/base.esm.js +1 -1
- package/dist/beta-desktop-bar.cjs.js +1 -1
- package/dist/beta-desktop-bar.esm.js +1 -1
- package/dist/beta-mobile-bar.cjs.js +1 -1
- package/dist/beta-mobile-bar.esm.js +1 -1
- package/dist/bundle.umd.js +1 -1
- package/dist/button.cjs.js +1 -1
- package/dist/button.esm.js +1 -1
- package/dist/center.cjs.js +1 -1
- package/dist/center.esm.js +1 -1
- package/dist/checkbox.cjs.js +1 -1
- package/dist/checkbox.esm.js +1 -1
- package/dist/code.cjs.js +1 -1
- package/dist/code.esm.js +1 -1
- package/dist/container.cjs.js +1 -1
- package/dist/container.esm.js +1 -1
- package/dist/data-list.cjs.js +1 -1
- package/dist/data-list.esm.js +1 -1
- package/dist/designer.cjs.js +1 -1
- package/dist/designer.esm.js +1 -1
- package/dist/dropdown.cjs.js +1 -1
- package/dist/dropdown.esm.js +1 -1
- package/dist/element-mapper.cjs.js +1 -1
- package/dist/element-mapper.esm.js +1 -1
- package/dist/finalresult.esm.js +1 -1
- package/dist/flex-card.cjs.js +1 -1
- package/dist/flex-card.esm.js +1 -1
- package/dist/flex-grid.cjs.js +1 -1
- package/dist/flex-grid.esm.js +1 -1
- package/dist/flex-row.cjs.js +1 -1
- package/dist/flex-row.esm.js +1 -1
- package/dist/floating-input.cjs.js +1 -1
- package/dist/floating-input.esm.js +1 -1
- package/dist/free.cjs.js +1 -1
- package/dist/free.esm.js +1 -1
- package/dist/horizontal-scroller.cjs.js +1 -1
- package/dist/horizontal-scroller.esm.js +1 -1
- package/dist/image-picker.cjs.js +1 -1
- package/dist/image-picker.esm.js +1 -1
- package/dist/image.cjs.js +1 -1
- package/dist/image.esm.js +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/link-getter.cjs.js +1 -1
- package/dist/link-getter.esm.js +1 -1
- package/dist/link.cjs.js +1 -1
- package/dist/link.esm.js +1 -1
- package/dist/meta-adder.cjs.js +1 -1
- package/dist/meta-adder.esm.js +1 -1
- package/dist/modal-2025.cjs.js +1 -1
- package/dist/modal-2025.esm.js +1 -1
- package/dist/multiswitcher.cjs.js +1 -1
- package/dist/multiswitcher.esm.js +1 -1
- package/dist/new-nav-bar.cjs.js +1 -1
- package/dist/new-nav-bar.esm.js +1 -1
- package/dist/picker.cjs.js +1 -1
- package/dist/picker.esm.js +1 -1
- package/dist/progress.cjs.js +1 -1
- package/dist/progress.esm.js +1 -1
- package/dist/radio.cjs.js +1 -1
- package/dist/radio.esm.js +1 -1
- package/dist/range.cjs.js +1 -1
- package/dist/range.esm.js +1 -1
- package/dist/side-bar.cjs.js +1 -1
- package/dist/side-bar.esm.js +1 -1
- package/dist/side-nav-bar.cjs.js +1 -1
- package/dist/side-nav-bar.esm.js +1 -1
- package/dist/simple-bar.cjs.js +1 -1
- package/dist/simple-bar.esm.js +1 -1
- package/dist/slider-2025.cjs.js +1 -1
- package/dist/slider-2025.esm.js +1 -1
- package/dist/spacer.cjs.js +1 -1
- package/dist/spacer.esm.js +1 -1
- package/dist/stack.cjs.js +1 -1
- package/dist/stack.esm.js +1 -1
- package/dist/table.cjs.js +1 -1
- package/dist/table.esm.js +1 -1
- package/dist/text-field.cjs.js +1 -1
- package/dist/text-field.esm.js +1 -1
- package/dist/text.cjs.js +1 -1
- package/dist/text.esm.js +1 -1
- package/dist/ulist.cjs.js +1 -1
- package/dist/ulist.esm.js +1 -1
- package/dist/video.cjs.js +1 -1
- package/dist/video.esm.js +1 -1
- package/dist/wrap.cjs.js +1 -1
- package/dist/wrap.esm.js +1 -1
- package/dist/zoom-card.cjs.js +1 -1
- package/dist/zoom-card.esm.js +1 -1
- package/layout/animator.js +46 -12
- package/layout/audio.js +1 -1
- package/layout/audionew.js +1 -1
- package/layout/base-2.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/box.js +1 -1
- package/layout/button.js +1 -1
- package/layout/cards.js +1 -1
- package/layout/center.js +1 -1
- package/layout/checkbox.js +1 -1
- package/layout/circle.js +1 -1
- package/layout/clean-row.js +1 -1
- package/layout/code.js +1 -1
- package/layout/container.js +1 -1
- package/layout/custom.js +1 -1
- package/layout/div-image.js +1 -1
- package/layout/dropdown-2025.js +1 -1
- package/layout/dropdown.js +1 -1
- package/layout/empty-element.js +1 -1
- package/layout/external-stylesheet.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/footer.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-new.js +1 -1
- package/layout/grid-switcher.js +1 -1
- package/layout/grid.js +1 -1
- package/layout/group.js +1 -1
- package/layout/header.js +1 -1
- package/layout/horizontal-scroller.js +1 -1
- package/layout/image-old.js +1 -1
- package/layout/image.js +1 -1
- package/layout/index.js +1 -1
- package/layout/label.js +1 -1
- package/layout/link.js +1 -1
- package/layout/list-OLD.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/modernwrap.js +1 -1
- package/layout/multiswitcher.js +1 -1
- package/layout/multiswitcherBeta.js +1 -1
- package/layout/nav-bar.js +1 -1
- package/layout/nav-factor/custom-div.js +1 -1
- package/layout/navBar-OLD.js +1 -1
- package/layout/new-flat-adder.js +1 -1
- package/layout/new-nav-bar.js +15 -4
- package/layout/offset-container.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/saved-new-nav-bar.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/styler.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/without-new.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/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/raster-ops.js +676 -58
- package/lib/scroll-video.js +1 -1
- package/lib/seo.js +1 -1
- package/lib/stacker.js +1 -1
- package/lib/theme.js +1 -1
- package/lib/transform-anim.js +1 -1
- package/package.json +1 -1
package/lib/raster-ops.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* nodality v1.0.
|
|
2
|
+
* nodality v1.0.188
|
|
3
3
|
* (c) 2026 Filip Vabrousek
|
|
4
4
|
* License: MIT
|
|
5
5
|
*/
|
|
@@ -57,6 +57,10 @@ const MAX_BLOBS = 12;
|
|
|
57
57
|
// 0..1 that the op scales its effect by. Ops read them as u<i>_dpos and
|
|
58
58
|
// u<i>_damt; the pipeline evaluates and uploads them once per frame.
|
|
59
59
|
const DRIVERS = {
|
|
60
|
+
// Centre of the element at full strength. The default for ops that
|
|
61
|
+
// need a focus point but were not given a `by:` -- without it their
|
|
62
|
+
// dpos/damt would never be uploaded and would read as zero.
|
|
63
|
+
static: (c) => ({ x: c.w * 0.5, y: c.h * 0.5, amt: 1 }),
|
|
60
64
|
mouse: (c) => ({ x: c.mouseX, y: c.mouseY, amt: 1 }),
|
|
61
65
|
// Same focus as mouse, but faded in and out with the hover state, so
|
|
62
66
|
// the effect resolves away when the pointer leaves.
|
|
@@ -259,6 +263,280 @@ const REGISTRY = {
|
|
|
259
263
|
}),
|
|
260
264
|
},
|
|
261
265
|
|
|
266
|
+
// ── Field producers ──────────────────────────────────────────────
|
|
267
|
+
// These draw nothing. They write a scalar field that LATER ops read
|
|
268
|
+
// via `masked:`, which is the whole of the node-to-node data flow:
|
|
269
|
+
//
|
|
270
|
+
// { op: "mask", from: "radial", as: "centre", radius: 300 },
|
|
271
|
+
// { op: "stir", masked: "centre" }, // fluid only in the middle
|
|
272
|
+
// { op: "halftone" }, // screen everywhere
|
|
273
|
+
//
|
|
274
|
+
// The masking wrapper is generic, so an op does not know or care
|
|
275
|
+
// that it is being constrained — every op, including ones written
|
|
276
|
+
// before fields existed, can be masked.
|
|
277
|
+
|
|
278
|
+
// Geometric / content masks.
|
|
279
|
+
mask: {
|
|
280
|
+
stage: "field",
|
|
281
|
+
producesField: true,
|
|
282
|
+
defaultDriver: "static",
|
|
283
|
+
decl: (p) => `
|
|
284
|
+
uniform vec2 ${p}dpos;
|
|
285
|
+
uniform float ${p}damt;
|
|
286
|
+
uniform float ${p}radius;
|
|
287
|
+
uniform vec2 ${p}at;
|
|
288
|
+
uniform vec2 ${p}remap;
|
|
289
|
+
uniform float ${p}invert;`,
|
|
290
|
+
code: (p, node) => {
|
|
291
|
+
const src = node.from || "radial";
|
|
292
|
+
// Where a radial mask sits. `at:` is fractional (0..1 of the
|
|
293
|
+
// element box) so it survives resizes; without it the focus
|
|
294
|
+
// comes from the driver, which defaults to the centre.
|
|
295
|
+
const ctr = node.at ? `(${p}at * u_res)` : `${p}dpos`;
|
|
296
|
+
const raw = src === "vertical"
|
|
297
|
+
? `frag.y / max(u_res.y, 1.0)`
|
|
298
|
+
: src === "horizontal"
|
|
299
|
+
? `frag.x / max(u_res.x, 1.0)`
|
|
300
|
+
: /* radial, and the default */
|
|
301
|
+
`1.0 - smoothstep(0.0, ${p}radius, length(frag - ${ctr}))`;
|
|
302
|
+
return `
|
|
303
|
+
{
|
|
304
|
+
float m = ${raw};
|
|
305
|
+
m = clamp((m - ${p}remap.x) / max(${p}remap.y - ${p}remap.x, 1e-4), 0.0, 1.0);
|
|
306
|
+
m = mix(m, 1.0 - m, ${p}invert);
|
|
307
|
+
${fieldVar(node.as || "mask")} *= m * ${p}damt;
|
|
308
|
+
}`;
|
|
309
|
+
},
|
|
310
|
+
uniforms: (node, dpr) => ({
|
|
311
|
+
radius: ["1f", (node.radius != null ? node.radius : 260) * dpr],
|
|
312
|
+
at: ["2fv", node.at || [0.5, 0.5]],
|
|
313
|
+
remap: ["2fv", node.remap || [0, 1]],
|
|
314
|
+
invert: ["1f", node.invert ? 1 : 0],
|
|
315
|
+
}),
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
// Animated value noise as a field. The Houdini move: make an
|
|
319
|
+
// attribute, then reference it downstream.
|
|
320
|
+
//
|
|
321
|
+
// { op: "noise", as: "turbulence", scale: 3, speed: 0.4 },
|
|
322
|
+
// { op: "offset", masked: "turbulence", strength: 30 },
|
|
323
|
+
noise: {
|
|
324
|
+
stage: "field",
|
|
325
|
+
producesField: true,
|
|
326
|
+
decl: (p) => `
|
|
327
|
+
uniform float ${p}scale;
|
|
328
|
+
uniform float ${p}speed;
|
|
329
|
+
uniform vec2 ${p}remap;
|
|
330
|
+
uniform float ${p}amp;
|
|
331
|
+
float ${p}h(vec2 v) {
|
|
332
|
+
return fract(sin(dot(v, vec2(41.113, 289.717))) * 43758.545);
|
|
333
|
+
}
|
|
334
|
+
float ${p}n(vec2 v) {
|
|
335
|
+
vec2 i = floor(v), f = v - i;
|
|
336
|
+
f = f * f * (3.0 - 2.0 * f);
|
|
337
|
+
return mix(mix(${p}h(i), ${p}h(i + vec2(1.0, 0.0)), f.x),
|
|
338
|
+
mix(${p}h(i + vec2(0.0, 1.0)), ${p}h(i + vec2(1.0, 1.0)), f.x), f.y);
|
|
339
|
+
}`,
|
|
340
|
+
code: (p, node) => `
|
|
341
|
+
{
|
|
342
|
+
vec2 np = frag / max(u_res.y, 1.0) * ${p}scale;
|
|
343
|
+
// Two octaves: enough structure to read as turbulence
|
|
344
|
+
// without the cost of a full fBm.
|
|
345
|
+
float n = ${p}n(np + u_time * ${p}speed) * 0.65 +
|
|
346
|
+
${p}n(np * 2.7 - u_time * ${p}speed * 0.6) * 0.35;
|
|
347
|
+
n = clamp((n - ${p}remap.x) / max(${p}remap.y - ${p}remap.x, 1e-4), 0.0, 1.0);
|
|
348
|
+
${fieldVar(node.as || "mask")} *= mix(1.0, n, ${p}amp);
|
|
349
|
+
}`,
|
|
350
|
+
uniforms: (node) => ({
|
|
351
|
+
scale: ["1f", node.scale != null ? node.scale : 3],
|
|
352
|
+
speed: ["1f", node.speed != null ? node.speed : 0.3],
|
|
353
|
+
remap: ["2fv", node.remap || [0, 1]],
|
|
354
|
+
amp: ["1f", node.amount != null ? node.amount : 1],
|
|
355
|
+
}),
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
// Copy to points. The content is re-sampled at a set of points, each
|
|
359
|
+
// stamp scaled and rotated, and composited into the overlay layer so
|
|
360
|
+
// the copies sit above the original rather than replacing it.
|
|
361
|
+
//
|
|
362
|
+
// { op: "copy", count: 6, radius: 120, scale: 0.55, rotate: 0.3 }
|
|
363
|
+
// { op: "copy", points: [[0.2, 0.5], [0.8, 0.5]], scale: 0.4 }
|
|
364
|
+
//
|
|
365
|
+
// With `count`, the points are a ring around the driver focus, so
|
|
366
|
+
// `by: "mouse"` drags the whole instance set around. With explicit
|
|
367
|
+
// `points`, positions are fractional (0..1 of the element box) and
|
|
368
|
+
// survive resizes.
|
|
369
|
+
//
|
|
370
|
+
// Single pass, so the stamp count is fixed at compile time and the
|
|
371
|
+
// loop is unrolled: `count` is a structural parameter, not something
|
|
372
|
+
// to animate. Copies sample the ORIGINAL texture, not the result of
|
|
373
|
+
// earlier colour ops, so a copy of a halftoned element is a copy of
|
|
374
|
+
// the unhalftoned source with the screen applied over it.
|
|
375
|
+
copy: {
|
|
376
|
+
stage: "color",
|
|
377
|
+
defaultDriver: "static",
|
|
378
|
+
decl: (p) => `
|
|
379
|
+
uniform vec2 ${p}dpos;
|
|
380
|
+
uniform float ${p}damt;
|
|
381
|
+
uniform float ${p}radius;
|
|
382
|
+
uniform float ${p}scale;
|
|
383
|
+
uniform float ${p}rotate;
|
|
384
|
+
uniform float ${p}fade;
|
|
385
|
+
uniform float ${p}amount;`,
|
|
386
|
+
code: (p, node) => {
|
|
387
|
+
const explicit = Array.isArray(node.points) && node.points.length > 0;
|
|
388
|
+
const n = explicit
|
|
389
|
+
? node.points.length
|
|
390
|
+
: Math.max(1, Math.min(24, Math.round(node.count || 5)));
|
|
391
|
+
let body = "";
|
|
392
|
+
for (let k = 0; k < n; k++) {
|
|
393
|
+
// Where this stamp is centred.
|
|
394
|
+
const centre = explicit
|
|
395
|
+
? `vec2(${(+node.points[k][0]).toFixed(5)}, ${(+node.points[k][1]).toFixed(5)}) * u_res`
|
|
396
|
+
: (() => {
|
|
397
|
+
const a = (k / n) * Math.PI * 2;
|
|
398
|
+
return `${p}dpos + vec2(${Math.cos(a).toFixed(5)}, ` +
|
|
399
|
+
`${Math.sin(a).toFixed(5)}) * ${p}radius`;
|
|
400
|
+
})();
|
|
401
|
+
body += `
|
|
402
|
+
{
|
|
403
|
+
float ang = ${p}rotate * ${(k + 1).toFixed(1)};
|
|
404
|
+
float cs = cos(ang), sn = sin(ang);
|
|
405
|
+
// Inverse transform: to draw a stamp scaled by s about a
|
|
406
|
+
// centre c, fetch from the source at (frag - c) / s.
|
|
407
|
+
vec2 d = mat2(cs, -sn, sn, cs) * (frag - (${centre}));
|
|
408
|
+
vec2 q = d / max(${p}scale, 1e-3) + u_res * 0.5;
|
|
409
|
+
vec2 quv = vec2(q.x / u_res.x, 1.0 - q.y / u_res.y);
|
|
410
|
+
vec2 inside = step(vec2(0.0), quv) * step(quv, vec2(1.0));
|
|
411
|
+
vec4 s = texture2D(u_tex, clamp(quv, 0.0, 1.0));
|
|
412
|
+
float w = s.a * inside.x * inside.y * ${p}amount * ${p}damt
|
|
413
|
+
* pow(${p}fade, ${k.toFixed(1)});
|
|
414
|
+
// Straight-alpha OVER, newest stamp on top.
|
|
415
|
+
float na = w + ovA * (1.0 - w);
|
|
416
|
+
ovCol = (s.rgb * w + ovCol * ovA * (1.0 - w)) / max(na, 1e-4);
|
|
417
|
+
ovA = na;
|
|
418
|
+
}`;
|
|
419
|
+
}
|
|
420
|
+
return body;
|
|
421
|
+
},
|
|
422
|
+
uniforms: (node, dpr) => ({
|
|
423
|
+
radius: ["1f", (node.radius != null ? node.radius : 110) * dpr],
|
|
424
|
+
scale: ["1f", node.scale != null ? node.scale : 0.5],
|
|
425
|
+
rotate: ["1f", node.rotate != null ? node.rotate : 0],
|
|
426
|
+
fade: ["1f", node.fade != null ? node.fade : 1],
|
|
427
|
+
amount: ["1f", node.amount != null ? node.amount : 1],
|
|
428
|
+
}),
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
// Merge two sub-chains and blend the results.
|
|
432
|
+
//
|
|
433
|
+
// { op: "merge", mode: "screen", mix: 1.0,
|
|
434
|
+
// a: [ { op: "halftone" } ],
|
|
435
|
+
// b: [ { op: "duotone", colors: [...] } ] }
|
|
436
|
+
//
|
|
437
|
+
// Both branches start from the same state and neither sees the
|
|
438
|
+
// other's output — that is the difference between a merge and a
|
|
439
|
+
// chain. `mix` fades the whole merge back toward branch A, so
|
|
440
|
+
// `mix: 0` is A alone and `mix: 1` is the full blend.
|
|
441
|
+
//
|
|
442
|
+
// The shader work is emitted inline, so a merge costs no extra pass,
|
|
443
|
+
// no extra framebuffer and no second capture of the DOM. It cannot,
|
|
444
|
+
// for the same reason, give the branches different source content.
|
|
445
|
+
merge: {
|
|
446
|
+
// Never routed through the normal stage path — emitMerge()
|
|
447
|
+
// handles it — but it still needs decl/uniforms so its `mix`
|
|
448
|
+
// gets declared and uploaded like any other op's.
|
|
449
|
+
stage: "color",
|
|
450
|
+
decl: (p) => `uniform float ${p}mix;`,
|
|
451
|
+
code: () => "",
|
|
452
|
+
uniforms: (node) => ({ mix: ["1f", node.mix != null ? node.mix : 1] }),
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
// Echo: temporal accumulation. Each frame is blended into a
|
|
456
|
+
// persistent full-resolution buffer, so content that CHANGES leaves a
|
|
457
|
+
// fading trail behind it — typing smears, a counter ticking over
|
|
458
|
+
// leaves a comet, a scrolling list ghosts.
|
|
459
|
+
//
|
|
460
|
+
// This is the one op that cannot work on the snapshot backend. A
|
|
461
|
+
// snapshot is a single frozen image, so accumulating it converges to
|
|
462
|
+
// that same still frame and nothing moves; it needs the live
|
|
463
|
+
// HTML-in-Canvas capture, where the texture is re-uploaded from the
|
|
464
|
+
// real DOM on every paint. Run it with the origin trial off and you
|
|
465
|
+
// correctly see nothing happen.
|
|
466
|
+
//
|
|
467
|
+
// { op: "echo", decay: 0.92, strength: 0.85 }
|
|
468
|
+
//
|
|
469
|
+
// The trail is written into the BORDER layer, which the frame
|
|
470
|
+
// skeleton composites underneath the content, so the ghosts sit
|
|
471
|
+
// behind the live text rather than veiling it.
|
|
472
|
+
echo: {
|
|
473
|
+
stage: "color",
|
|
474
|
+
decl: (p) => `
|
|
475
|
+
uniform sampler2D ${p}hist;
|
|
476
|
+
uniform float ${p}strength;
|
|
477
|
+
uniform vec3 ${p}tint;
|
|
478
|
+
uniform float ${p}tintAmt;`,
|
|
479
|
+
code: (p) => `
|
|
480
|
+
{
|
|
481
|
+
// Sampled in screen space: the trail records where things
|
|
482
|
+
// WERE, which is not where a warp op would fetch them from.
|
|
483
|
+
vec2 huv = vec2(frag.x / u_res.x, 1.0 - frag.y / u_res.y);
|
|
484
|
+
vec4 h = texture2D(${p}hist, clamp(huv, 0.001, 0.999));
|
|
485
|
+
float ha = clamp(h.a * ${p}strength, 0.0, 1.0);
|
|
486
|
+
vec3 hc = mix(h.rgb, ${p}tint, ${p}tintAmt);
|
|
487
|
+
// Straight-alpha OVER into the border layer so an edges op
|
|
488
|
+
// in the same chain still composites correctly.
|
|
489
|
+
float na = ha + edgeCov * (1.0 - ha);
|
|
490
|
+
edgeCol = (hc * ha + edgeCol * edgeCov * (1.0 - ha)) / max(na, 1e-4);
|
|
491
|
+
edgeCov = na;
|
|
492
|
+
}`,
|
|
493
|
+
uniforms: (node) => ({
|
|
494
|
+
strength: ["1f", node.strength != null ? node.strength : 0.85],
|
|
495
|
+
tint: ["3fv", hexToRgb(node.tint || "#7FD4FF")],
|
|
496
|
+
tintAmt: ["1f", node.tint ? (node.tintAmount != null ? node.tintAmount : 0.5) : 0.0],
|
|
497
|
+
}),
|
|
498
|
+
solver: {
|
|
499
|
+
// Full canvas resolution: a trail wants to be as sharp as the
|
|
500
|
+
// content it is trailing, not a coarse simulation grid.
|
|
501
|
+
resolutions: (node, w, h) => ({ frame: [w, h] }),
|
|
502
|
+
targets: { history: { double: true, res: "frame", fmt: "rgba", smooth: true } },
|
|
503
|
+
samplers: { hist: "history" },
|
|
504
|
+
programs: {
|
|
505
|
+
// out = max(content, history * decay)
|
|
506
|
+
//
|
|
507
|
+
// max rather than a mix: the brightest thing that has
|
|
508
|
+
// passed through a pixel is what persists, so trails read
|
|
509
|
+
// as light left behind instead of a muddy average. decay
|
|
510
|
+
// is per-frame and normalised for frame rate.
|
|
511
|
+
accumulate: `
|
|
512
|
+
precision highp float;
|
|
513
|
+
uniform sampler2D u_content;
|
|
514
|
+
uniform sampler2D u_hist;
|
|
515
|
+
uniform vec2 u_texel;
|
|
516
|
+
uniform float u_decay;
|
|
517
|
+
void main() {
|
|
518
|
+
vec2 uv = gl_FragCoord.xy * u_texel;
|
|
519
|
+
vec4 c = texture2D(u_content, uv);
|
|
520
|
+
vec4 h = texture2D(u_hist, uv) * u_decay;
|
|
521
|
+
gl_FragColor = max(c, h);
|
|
522
|
+
}`,
|
|
523
|
+
},
|
|
524
|
+
// echo takes no pointer input, so nothing to splat.
|
|
525
|
+
splatColor: () => [0, 0, 0],
|
|
526
|
+
step: (S, node, ctx) => {
|
|
527
|
+
const dt = Math.min(ctx.dt, 1 / 30);
|
|
528
|
+
const decay = Math.pow(node.decay != null ? node.decay : 0.92, dt * 60);
|
|
529
|
+
S.use("accumulate");
|
|
530
|
+
S.f2("u_texel", 1 / S.canvasW, 1 / S.canvasH);
|
|
531
|
+
S.f1("u_decay", decay);
|
|
532
|
+
S.smp("u_content", S.content, 0);
|
|
533
|
+
S.smp("u_hist", S.read("history"), 1);
|
|
534
|
+
S.blit(S.write("history"));
|
|
535
|
+
S.swap("history");
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
|
|
262
540
|
// Stirred liquid: an incompressible fluid whose velocity field warps
|
|
263
541
|
// the content and whose dye field colours it.
|
|
264
542
|
//
|
|
@@ -864,7 +1142,11 @@ function hexToRgb(c) {
|
|
|
864
1142
|
// Names designer.js uses to route nodes into this pipeline instead of
|
|
865
1143
|
// the CSS-level op loop. Kept as a live array so registerRasterOp()
|
|
866
1144
|
// extends routing too.
|
|
867
|
-
|
|
1145
|
+
// `switch` has no REGISTRY entry — it is resolved to a node list before
|
|
1146
|
+
// the shader is built — but it still has to be routed to elements like
|
|
1147
|
+
// any other raster node, so it is named here explicitly. Only the switch
|
|
1148
|
+
// node carries `target:`; the sub-chains inherit whatever it matched.
|
|
1149
|
+
const RASTER_OP_NAMES = Object.keys(REGISTRY).concat("switch");
|
|
868
1150
|
|
|
869
1151
|
// Extension surface — mirrors the CSS-level operation registry:
|
|
870
1152
|
// registerRasterOp("pixelate", { stage, decl, code, uniforms }).
|
|
@@ -882,25 +1164,165 @@ function isHTMLInCanvasAvailable() {
|
|
|
882
1164
|
|
|
883
1165
|
const VS = `attribute vec2 a; void main(){ gl_Position = vec4(a, 0.0, 1.0); }`;
|
|
884
1166
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1167
|
+
// What each stage is allowed to modify. Masking works by snapshotting
|
|
1168
|
+
// these before an op runs and lerping back toward the snapshot by the
|
|
1169
|
+
// mask value, so ANY op can be masked without the op knowing about it.
|
|
1170
|
+
const STAGE_VARS = {
|
|
1171
|
+
warp: ["warped"],
|
|
1172
|
+
cell: ["center", "edge"],
|
|
1173
|
+
displace: ["sampleP", "chroma"],
|
|
1174
|
+
color: ["col", "edgeCol", "edgeCov", "ovCol", "ovA"],
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
// A node opts into a field with `masked: true` (the default field) or
|
|
1178
|
+
// `masked: "name"`. Field producers declare `as:` to name what they
|
|
1179
|
+
// write. This is the whole of the node-to-node data flow: one op writes
|
|
1180
|
+
// a scalar field, later ops read it.
|
|
1181
|
+
// No double underscores anywhere: GLSL reserves identifiers
|
|
1182
|
+
// containing `__` as possible future keywords, and Chrome rejects
|
|
1183
|
+
// the shader outright rather than warning.
|
|
1184
|
+
const fieldVar = (name) => `fld_${String(name).replace(/[^A-Za-z0-9_]/g, "")}`;
|
|
1185
|
+
const maskedField = (node) =>
|
|
1186
|
+
node.masked === true ? "mask" : (typeof node.masked === "string" ? node.masked : null);
|
|
1187
|
+
|
|
1188
|
+
const glslType = (v) =>
|
|
1189
|
+
(v === "edge" || v === "edgeCov" || v === "ovA") ? "float"
|
|
1190
|
+
: (v === "col" || v === "edgeCol" || v === "ovCol") ? "vec3"
|
|
1191
|
+
: "vec2";
|
|
1192
|
+
|
|
1193
|
+
// How `merge` combines its two branches. Applied to the vec3 stage
|
|
1194
|
+
// variables only; the scalars and coordinates are plainly interpolated,
|
|
1195
|
+
// because "screen" is meaningless for a displacement.
|
|
1196
|
+
const BLEND = {
|
|
1197
|
+
over: (a, b) => b,
|
|
1198
|
+
add: (a, b) => `min(${a} + ${b}, vec3(1.0))`,
|
|
1199
|
+
screen: (a, b) => `1.0 - (1.0 - ${a}) * (1.0 - ${b})`,
|
|
1200
|
+
multiply: (a, b) => `${a} * ${b}`,
|
|
1201
|
+
difference: (a, b) => `abs(${a} - ${b})`,
|
|
1202
|
+
lighten: (a, b) => `max(${a}, ${b})`,
|
|
1203
|
+
darken: (a, b) => `min(${a}, ${b})`,
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
const emptyBuckets = () =>
|
|
1207
|
+
({ field: "", warp: "", cell: "", displace: "", color: "" });
|
|
1208
|
+
|
|
1209
|
+
// Flatten a node tree (merge nodes carry sub-chains) into the linear
|
|
1210
|
+
// list that owns the uniform slots. Depth-first, parent before children,
|
|
1211
|
+
// so `u<i>_` prefixes are stable and every consumer — shader emission,
|
|
1212
|
+
// uniform upload, drivers, solvers — agrees on which node is index i.
|
|
1213
|
+
// Returns records rather than mutating the caller's node objects.
|
|
1214
|
+
function flattenRaster(nodes, flat) {
|
|
1215
|
+
const recs = [];
|
|
1216
|
+
for (const node of (nodes || [])) {
|
|
1217
|
+
if (!node || !node.op) continue;
|
|
1218
|
+
const rec = { node: node, i: flat.length };
|
|
1219
|
+
flat.push(node);
|
|
1220
|
+
if (node.op === "merge") {
|
|
1221
|
+
rec.a = flattenRaster(node.a, flat);
|
|
1222
|
+
rec.b = flattenRaster(node.b, flat);
|
|
1223
|
+
}
|
|
1224
|
+
recs.push(rec);
|
|
1225
|
+
}
|
|
1226
|
+
return recs;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function emitStages(recs, buckets) {
|
|
1230
|
+
for (const rec of recs) {
|
|
1231
|
+
const node = rec.node;
|
|
1232
|
+
if (node.op === "merge") { emitMerge(rec, buckets); continue; }
|
|
888
1233
|
const def = REGISTRY[node.op];
|
|
889
|
-
if (!def)
|
|
890
|
-
const p = `u${i}_`;
|
|
891
|
-
decls += def.decl(p, node) + "\n";
|
|
1234
|
+
if (!def) continue;
|
|
1235
|
+
const p = `u${rec.i}_`;
|
|
892
1236
|
// An op may contribute to more than one stage (stage: ["warp",
|
|
893
1237
|
// "color"]) — code() then receives the stage it is emitting for.
|
|
894
1238
|
// A plain string stage keeps the original single-snippet form.
|
|
895
1239
|
const stages = Array.isArray(def.stage) ? def.stage : [def.stage];
|
|
1240
|
+
const mask = maskedField(node);
|
|
896
1241
|
stages.forEach((st) => {
|
|
897
|
-
|
|
898
|
-
if (
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
1242
|
+
let snippet = def.code(p, node, st) + "\n";
|
|
1243
|
+
if (mask && STAGE_VARS[st]) {
|
|
1244
|
+
// Generic masking wrapper: no op needs to know it is
|
|
1245
|
+
// being masked, which is what lets a field constrain
|
|
1246
|
+
// ops written long before fields existed.
|
|
1247
|
+
const vars = STAGE_VARS[st];
|
|
1248
|
+
const snap = vars.map((v, k) =>
|
|
1249
|
+
` ${glslType(v)} msv${k} = ${v};`).join("\n");
|
|
1250
|
+
const back = vars.map((v, k) =>
|
|
1251
|
+
` ${v} = mix(msv${k}, ${v}, ${fieldVar(mask)});`).join("\n");
|
|
1252
|
+
snippet = ` {\n${snap}\n${snippet}\n${back}\n }\n`;
|
|
1253
|
+
}
|
|
1254
|
+
// `field` has no STAGE_VARS entry (it writes named fields,
|
|
1255
|
+
// not stage variables), so it is listed explicitly here —
|
|
1256
|
+
// anything unrecognised still falls through to colour.
|
|
1257
|
+
const bucket = (st === "field" || st === "warp" ||
|
|
1258
|
+
st === "cell" || st === "displace") ? st : "color";
|
|
1259
|
+
buckets[bucket] += snippet;
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// merge: run two sub-chains from the same starting state and combine
|
|
1265
|
+
// them. Still one pass — each branch's stage code is emitted into its
|
|
1266
|
+
// own scope, the stage variables are rewound between branches, and the
|
|
1267
|
+
// two results are blended. That is what makes it free: no second
|
|
1268
|
+
// framebuffer, no second capture of the DOM.
|
|
1269
|
+
//
|
|
1270
|
+
// The consequence is that a branch cannot see the other's output, only
|
|
1271
|
+
// the shared input, which is exactly the semantics you want for a merge
|
|
1272
|
+
// and not the semantics of a chain.
|
|
1273
|
+
function emitMerge(rec, buckets) {
|
|
1274
|
+
const p = `u${rec.i}_`;
|
|
1275
|
+
const blend = BLEND[rec.node.mode] || BLEND.over;
|
|
1276
|
+
const A = emptyBuckets(), B = emptyBuckets();
|
|
1277
|
+
emitStages(rec.a || [], A);
|
|
1278
|
+
emitStages(rec.b || [], B);
|
|
1279
|
+
// Fields are shared state, not branch-local: a mask written inside
|
|
1280
|
+
// one branch is visible to the other, and to everything after.
|
|
1281
|
+
buckets.field += A.field + B.field;
|
|
1282
|
+
for (const st of ["warp", "cell", "displace", "color"]) {
|
|
1283
|
+
if (!A[st] && !B[st]) continue;
|
|
1284
|
+
const vars = STAGE_VARS[st];
|
|
1285
|
+
let s = " {\n";
|
|
1286
|
+
vars.forEach((v, k) => { s += ` ${glslType(v)} pre${k} = ${v};\n`; });
|
|
1287
|
+
s += A[st];
|
|
1288
|
+
vars.forEach((v, k) => { s += ` ${glslType(v)} bra${k} = ${v};\n`; });
|
|
1289
|
+
vars.forEach((v, k) => { s += ` ${v} = pre${k};\n`; });
|
|
1290
|
+
s += B[st];
|
|
1291
|
+
vars.forEach((v, k) => {
|
|
1292
|
+
const combined = glslType(v) === "vec3"
|
|
1293
|
+
? blend(`bra${k}`, v)
|
|
1294
|
+
: v;
|
|
1295
|
+
s += ` ${v} = mix(bra${k}, ${combined}, ${p}mix);\n`;
|
|
902
1296
|
});
|
|
1297
|
+
s += " }\n";
|
|
1298
|
+
buckets[st] += s;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function buildFragmentShader(recs, flat) {
|
|
1303
|
+
let decls = "";
|
|
1304
|
+
|
|
1305
|
+
// Every field any node writes or reads, declared up front at 1.0 so
|
|
1306
|
+
// an unmatched reference is a no-op rather than a compile error.
|
|
1307
|
+
const fieldNames = new Set();
|
|
1308
|
+
flat.forEach((n) => {
|
|
1309
|
+
const def = REGISTRY[n.op];
|
|
1310
|
+
if (def && def.producesField) fieldNames.add(n.as || "mask");
|
|
1311
|
+
const m = maskedField(n);
|
|
1312
|
+
if (m) fieldNames.add(m);
|
|
1313
|
+
});
|
|
1314
|
+
const fieldDecls = [...fieldNames]
|
|
1315
|
+
.map((n) => ` float ${fieldVar(n)} = 1.0;`).join("\n");
|
|
1316
|
+
|
|
1317
|
+
flat.forEach((node, i) => {
|
|
1318
|
+
const def = REGISTRY[node.op];
|
|
1319
|
+
if (def) decls += def.decl(`u${i}_`, node) + "\n";
|
|
903
1320
|
});
|
|
1321
|
+
|
|
1322
|
+
const buckets = emptyBuckets();
|
|
1323
|
+
emitStages(recs, buckets);
|
|
1324
|
+
const { field, warp, cell, displace, color } = buckets;
|
|
1325
|
+
const nodes = flat;
|
|
904
1326
|
// Compositing, in three layers (all straight-alpha):
|
|
905
1327
|
// 1. border layer (edgeCol/edgeCov, from an edges op) UNDER
|
|
906
1328
|
// 2. content layer (col over tex.a) then
|
|
@@ -926,6 +1348,8 @@ uniform float u_time;
|
|
|
926
1348
|
${decls}
|
|
927
1349
|
void main() {
|
|
928
1350
|
vec2 frag = vec2(gl_FragCoord.x, u_res.y - gl_FragCoord.y);
|
|
1351
|
+
${fieldDecls}
|
|
1352
|
+
${field}
|
|
929
1353
|
vec2 warped = frag;
|
|
930
1354
|
${warp}
|
|
931
1355
|
vec2 center = warped;
|
|
@@ -955,9 +1379,38 @@ ${defaultSeam}
|
|
|
955
1379
|
|
|
956
1380
|
// ── Snapshot backend: DOM subtree -> SVG foreignObject -> texture ────
|
|
957
1381
|
|
|
1382
|
+
// Viewport-relative CSS inside a foreignObject resolves against the SVG
|
|
1383
|
+
// element's own size, not the browser viewport — so `font-size: calc(
|
|
1384
|
+
// 1.625rem + 5.075vw)` renders at a different size in the snapshot than
|
|
1385
|
+
// it does on the page. The glyphs then sit somewhere the DOM text does
|
|
1386
|
+
// not, which is what makes the selection highlight look offset.
|
|
1387
|
+
//
|
|
1388
|
+
// Freeze those declarations to the pixel values the element actually
|
|
1389
|
+
// computes, on a clone, so the snapshot matches the live layout.
|
|
1390
|
+
const VIEWPORT_UNIT = /\d(?:vw|vh|vmin|vmax|dvw|dvh|svw|svh|lvw|lvh)\b/i;
|
|
1391
|
+
|
|
1392
|
+
function freezeViewportUnits(original, clone) {
|
|
1393
|
+
const origs = [original, ...original.querySelectorAll("*")];
|
|
1394
|
+
const clones = [clone, ...clone.querySelectorAll("*")];
|
|
1395
|
+
for (let i = 0; i < origs.length && i < clones.length; i++) {
|
|
1396
|
+
const inline = clones[i].style;
|
|
1397
|
+
if (!inline || inline.length === 0) continue;
|
|
1398
|
+
let computed = null;
|
|
1399
|
+
// Iterate a snapshot of the names: writing to style mutates the list.
|
|
1400
|
+
for (const prop of Array.from(inline)) {
|
|
1401
|
+
if (!VIEWPORT_UNIT.test(inline.getPropertyValue(prop))) continue;
|
|
1402
|
+
computed = computed || window.getComputedStyle(origs[i]);
|
|
1403
|
+
const px = computed.getPropertyValue(prop);
|
|
1404
|
+
if (px) inline.setProperty(prop, px, inline.getPropertyPriority(prop));
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
return clone;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
958
1410
|
function snapshotToImage(el, w, h, dpr) {
|
|
959
1411
|
return new Promise((resolve, reject) => {
|
|
960
|
-
const serialized = new XMLSerializer()
|
|
1412
|
+
const serialized = new XMLSerializer()
|
|
1413
|
+
.serializeToString(freezeViewportUnits(el, el.cloneNode(true)));
|
|
961
1414
|
const svg =
|
|
962
1415
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${w * dpr}" height="${h * dpr}" viewBox="0 0 ${w} ${h}">` +
|
|
963
1416
|
`<foreignObject width="100%" height="100%">` +
|
|
@@ -970,6 +1423,59 @@ function snapshotToImage(el, w, h, dpr) {
|
|
|
970
1423
|
});
|
|
971
1424
|
}
|
|
972
1425
|
|
|
1426
|
+
// ── switch: pick a sub-chain by condition ────────────────────────────
|
|
1427
|
+
//
|
|
1428
|
+
// { op: "switch",
|
|
1429
|
+
// when: "(max-width: 700px)", // or "coarse", "fine", "no-hover"
|
|
1430
|
+
// use: [ { op: "halftone" } ], // taken when the test passes
|
|
1431
|
+
// else: [ { op: "stir" }, ... ] } // taken when it does not
|
|
1432
|
+
//
|
|
1433
|
+
// This is a build-time node, not a shader op: it produces a node list,
|
|
1434
|
+
// which is then compiled like any other. Sub-chains nest, so a switch
|
|
1435
|
+
// may contain a switch.
|
|
1436
|
+
//
|
|
1437
|
+
// Evaluated ONCE, when the pipeline attaches. It is meant for choices
|
|
1438
|
+
// that are properties of the device — a cheap chain on a phone, a heavy
|
|
1439
|
+
// one on a desktop — not for anything that should change as the window
|
|
1440
|
+
// is dragged: switching mid-session would need a new shader program,
|
|
1441
|
+
// and the pipeline compiles exactly one.
|
|
1442
|
+
const SWITCH_ALIASES = {
|
|
1443
|
+
coarse: "(pointer: coarse)",
|
|
1444
|
+
fine: "(pointer: fine)",
|
|
1445
|
+
hover: "(hover: hover)",
|
|
1446
|
+
"no-hover": "(hover: none)",
|
|
1447
|
+
"reduced-motion": "(prefers-reduced-motion: reduce)",
|
|
1448
|
+
dark: "(prefers-color-scheme: dark)",
|
|
1449
|
+
light: "(prefers-color-scheme: light)",
|
|
1450
|
+
};
|
|
1451
|
+
|
|
1452
|
+
function switchTest(when) {
|
|
1453
|
+
if (typeof when === "function") {
|
|
1454
|
+
try { return !!when(); } catch (e) { return false; }
|
|
1455
|
+
}
|
|
1456
|
+
if (typeof when === "boolean") return when;
|
|
1457
|
+
if (typeof when !== "string" || when === "") return false;
|
|
1458
|
+
const q = SWITCH_ALIASES[when] || when;
|
|
1459
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
|
1460
|
+
try { return window.matchMedia(q).matches; } catch (e) { return false; }
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function resolveSwitches(nodes, depth) {
|
|
1464
|
+
if (!Array.isArray(nodes)) return [];
|
|
1465
|
+
// Cheap cycle guard: sub-chains are plain data and could be shared
|
|
1466
|
+
// by reference, so a self-referential list would otherwise hang.
|
|
1467
|
+
if ((depth || 0) > 8) return [];
|
|
1468
|
+
const out = [];
|
|
1469
|
+
for (const node of nodes) {
|
|
1470
|
+
if (!node || node.op !== "switch") { out.push(node); continue; }
|
|
1471
|
+
const branch = switchTest(node.when) ? node.use : node.else;
|
|
1472
|
+
// A branch may legitimately be empty — that is how you say
|
|
1473
|
+
// "do nothing on small screens".
|
|
1474
|
+
for (const sub of resolveSwitches(branch || [], (depth || 0) + 1)) out.push(sub);
|
|
1475
|
+
}
|
|
1476
|
+
return out;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
973
1479
|
// ── Pipeline runner ──────────────────────────────────────────────────
|
|
974
1480
|
|
|
975
1481
|
function applyRasterPipeline(el, rasterNodes) {
|
|
@@ -978,6 +1484,20 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
978
1484
|
// page untouched.
|
|
979
1485
|
if (!el || typeof document === "undefined" || typeof window === "undefined") return null;
|
|
980
1486
|
if (!rasterNodes || rasterNodes.length === 0) return null;
|
|
1487
|
+
// Flatten any switch nodes down to the chain this device actually
|
|
1488
|
+
// gets, before anything reads the list.
|
|
1489
|
+
if (rasterNodes.some((n) => n && n.op === "switch")) {
|
|
1490
|
+
rasterNodes = resolveSwitches(rasterNodes, 0);
|
|
1491
|
+
if (rasterNodes.length === 0) return null;
|
|
1492
|
+
}
|
|
1493
|
+
// Flatten merge sub-chains into the linear list that owns the
|
|
1494
|
+
// uniform slots. `tree` keeps the branch structure for the shader
|
|
1495
|
+
// emitter; `flat` is what every other loop iterates, so a node
|
|
1496
|
+
// nested inside a merge gets its uniforms, driver and solver set up
|
|
1497
|
+
// exactly like a top-level one.
|
|
1498
|
+
const flatNodes = [];
|
|
1499
|
+
const rasterTree = flattenRaster(rasterNodes, flatNodes);
|
|
1500
|
+
rasterNodes = flatNodes;
|
|
981
1501
|
if (typeof window.matchMedia === "function" &&
|
|
982
1502
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches) return null;
|
|
983
1503
|
|
|
@@ -1067,6 +1587,16 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1067
1587
|
// chrome://flags/#canvas-draw-element). layoutsubtree was already
|
|
1068
1588
|
// set before context creation above.
|
|
1069
1589
|
const wrap = document.createElement("div");
|
|
1590
|
+
// The wrapper MUST fill the canvas box. Left to itself it
|
|
1591
|
+
// shrink-wraps its content — a headline wraps to the text width,
|
|
1592
|
+
// not the element width — and texElementImage2D then captures
|
|
1593
|
+
// that narrower box while the shader maps the texture across the
|
|
1594
|
+
// whole canvas. Everything comes out stretched horizontally by
|
|
1595
|
+
// canvasWidth/contentWidth, so the drawn glyphs no longer sit
|
|
1596
|
+
// where the DOM text does and selection lands on the wrong
|
|
1597
|
+
// characters.
|
|
1598
|
+
wrap.style.cssText =
|
|
1599
|
+
`display:block;width:${rect.width}px;height:${rect.height}px;`;
|
|
1070
1600
|
while (el.firstChild) wrap.appendChild(el.firstChild);
|
|
1071
1601
|
canvas.appendChild(wrap);
|
|
1072
1602
|
sourceEl = wrap;
|
|
@@ -1075,13 +1605,24 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1075
1605
|
canvas.style.cssText =
|
|
1076
1606
|
`display:block;width:${rect.width}px;height:${rect.height}px;`;
|
|
1077
1607
|
} else {
|
|
1078
|
-
// Snapshot mode: purely visual overlay
|
|
1079
|
-
// underneath for accessibility, selection and events.
|
|
1608
|
+
// Snapshot mode: purely visual overlay.
|
|
1080
1609
|
canvas.style.cssText =
|
|
1081
1610
|
"position:absolute;top:0;left:0;" +
|
|
1082
1611
|
`width:${rect.width}px;height:${rect.height}px;` +
|
|
1083
1612
|
"pointer-events:none;z-index:2147483000;";
|
|
1084
|
-
|
|
1613
|
+
if (pureOverlay) {
|
|
1614
|
+
// The real content is still visible underneath and keeps its
|
|
1615
|
+
// own semantics, so the canvas is pure decoration.
|
|
1616
|
+
canvas.setAttribute("aria-hidden", "true");
|
|
1617
|
+
} else {
|
|
1618
|
+
// In-place: the host gets hidden below, which would take its
|
|
1619
|
+
// text out of the accessibility tree, out of find-in-page and
|
|
1620
|
+
// out of innerText. Carry the label onto the canvas so the
|
|
1621
|
+
// element is still announced rather than silently vanishing.
|
|
1622
|
+
canvas.setAttribute("role", "img");
|
|
1623
|
+
const label = (el.textContent || "").replace(/\s+/g, " ").trim();
|
|
1624
|
+
if (label) canvas.setAttribute("aria-label", label.slice(0, 300));
|
|
1625
|
+
}
|
|
1085
1626
|
}
|
|
1086
1627
|
el.appendChild(canvas);
|
|
1087
1628
|
|
|
@@ -1097,7 +1638,7 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1097
1638
|
};
|
|
1098
1639
|
const prog = gl.createProgram();
|
|
1099
1640
|
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
|
|
1100
|
-
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, buildFragmentShader(rasterNodes)));
|
|
1641
|
+
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, buildFragmentShader(rasterTree, rasterNodes)));
|
|
1101
1642
|
// Pin "a" to location 0 so a field-simulation program (which shares
|
|
1102
1643
|
// this vertex buffer and attribute array) can be swapped in without
|
|
1103
1644
|
// respecifying the pointer.
|
|
@@ -1156,7 +1697,9 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1156
1697
|
return null;
|
|
1157
1698
|
}
|
|
1158
1699
|
gl.getExtension("OES_texture_float_linear");
|
|
1159
|
-
|
|
1700
|
+
// Canvas dimensions are passed through so a target can be
|
|
1701
|
+
// full-resolution (echo's history) rather than a fixed grid.
|
|
1702
|
+
const res = def.solver.resolutions(node, canvas.width, canvas.height);
|
|
1160
1703
|
const FMT = {
|
|
1161
1704
|
r: [gl.R16F, gl.RED],
|
|
1162
1705
|
rg: [gl.RG16F, gl.RG],
|
|
@@ -1164,7 +1707,7 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1164
1707
|
};
|
|
1165
1708
|
|
|
1166
1709
|
const made = [];
|
|
1167
|
-
const mkTarget = (
|
|
1710
|
+
const mkTarget = (w, h, fmt, smooth) => {
|
|
1168
1711
|
const [internal, format] = FMT[fmt];
|
|
1169
1712
|
const t = gl.createTexture();
|
|
1170
1713
|
gl.bindTexture(gl.TEXTURE_2D, t);
|
|
@@ -1173,17 +1716,17 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1173
1716
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, flt);
|
|
1174
1717
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
1175
1718
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
1176
|
-
gl.texImage2D(gl.TEXTURE_2D, 0, internal,
|
|
1719
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, internal, w, h, 0,
|
|
1177
1720
|
format, gl.HALF_FLOAT, null);
|
|
1178
1721
|
const fbo = gl.createFramebuffer();
|
|
1179
1722
|
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
|
1180
1723
|
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0,
|
|
1181
1724
|
gl.TEXTURE_2D, t, 0);
|
|
1182
1725
|
const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
|
|
1183
|
-
gl.viewport(0, 0,
|
|
1726
|
+
gl.viewport(0, 0, w, h);
|
|
1184
1727
|
gl.clearColor(0, 0, 0, 1);
|
|
1185
1728
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
1186
|
-
const rec = { tex: t, fbo,
|
|
1729
|
+
const rec = { tex: t, fbo, w, h, ok };
|
|
1187
1730
|
made.push(rec);
|
|
1188
1731
|
return rec;
|
|
1189
1732
|
};
|
|
@@ -1192,15 +1735,16 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1192
1735
|
let allOk = true;
|
|
1193
1736
|
for (const name in def.solver.targets) {
|
|
1194
1737
|
const spec = def.solver.targets[name];
|
|
1195
|
-
const
|
|
1738
|
+
const dim = res[spec.res];
|
|
1739
|
+
const [tw, th] = Array.isArray(dim) ? dim : [dim, dim];
|
|
1196
1740
|
if (spec.double) {
|
|
1197
1741
|
targets[name] = {
|
|
1198
|
-
a: mkTarget(
|
|
1199
|
-
b: mkTarget(
|
|
1742
|
+
a: mkTarget(tw, th, spec.fmt, spec.smooth),
|
|
1743
|
+
b: mkTarget(tw, th, spec.fmt, spec.smooth),
|
|
1200
1744
|
};
|
|
1201
1745
|
allOk = allOk && targets[name].a.ok && targets[name].b.ok;
|
|
1202
1746
|
} else {
|
|
1203
|
-
targets[name] = { a: mkTarget(
|
|
1747
|
+
targets[name] = { a: mkTarget(tw, th, spec.fmt, spec.smooth) };
|
|
1204
1748
|
allOk = allOk && targets[name].a.ok;
|
|
1205
1749
|
}
|
|
1206
1750
|
}
|
|
@@ -1275,13 +1819,18 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1275
1819
|
read: (name) => s.targets[name].a,
|
|
1276
1820
|
write: (name) => s.targets[name].b,
|
|
1277
1821
|
single: (name) => s.targets[name].a,
|
|
1822
|
+
// The captured page content, as a bindable target. echo
|
|
1823
|
+
// accumulates from it; stir never touches it.
|
|
1824
|
+
content: { tex },
|
|
1825
|
+
canvasW: canvas.width,
|
|
1826
|
+
canvasH: canvas.height,
|
|
1278
1827
|
swap(name) {
|
|
1279
1828
|
const t = s.targets[name];
|
|
1280
1829
|
const tmp = t.a; t.a = t.b; t.b = tmp;
|
|
1281
1830
|
},
|
|
1282
1831
|
blit(target) {
|
|
1283
1832
|
gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo);
|
|
1284
|
-
gl.viewport(0, 0, target.
|
|
1833
|
+
gl.viewport(0, 0, target.w, target.h);
|
|
1285
1834
|
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
1286
1835
|
},
|
|
1287
1836
|
};
|
|
@@ -1335,6 +1884,65 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1335
1884
|
// texture is live, the host's own painting is hidden via the
|
|
1336
1885
|
// visibility trick (the canvas child re-shows itself) so transparent
|
|
1337
1886
|
// hosts — e.g. a bare headline — don't ghost-double under the effect.
|
|
1887
|
+
// Keeping the host selectable.
|
|
1888
|
+
// --------------------------------------------------------------
|
|
1889
|
+
// The host must NOT be visibility:hidden. Hidden text cannot be
|
|
1890
|
+
// selected, so dragging over the effect either selects nothing or
|
|
1891
|
+
// catches neighbouring content — which is what makes the highlight
|
|
1892
|
+
// look offset and unreliable. Instead the host stays in the flow
|
|
1893
|
+
// with its ink made transparent, and the canvas is painted BEHIND
|
|
1894
|
+
// it. The glyphs are invisible but still hit-testable, and the
|
|
1895
|
+
// browser paints the selection highlight above the canvas, aligned
|
|
1896
|
+
// with the real text, because that is where the text actually is.
|
|
1897
|
+
const INK = {
|
|
1898
|
+
"color": "transparent",
|
|
1899
|
+
"-webkit-text-fill-color": "transparent",
|
|
1900
|
+
"-webkit-text-stroke-color": "transparent",
|
|
1901
|
+
"background-color": "transparent",
|
|
1902
|
+
"background-image": "none",
|
|
1903
|
+
"border-color": "transparent",
|
|
1904
|
+
"box-shadow": "none",
|
|
1905
|
+
"text-shadow": "none",
|
|
1906
|
+
};
|
|
1907
|
+
const REPLACED = "img,svg,video,picture,iframe";
|
|
1908
|
+
let inkSaved = null;
|
|
1909
|
+
|
|
1910
|
+
const hideHostInk = () => {
|
|
1911
|
+
if (inkSaved) return;
|
|
1912
|
+
inkSaved = [];
|
|
1913
|
+
for (const node of [el, ...el.querySelectorAll("*")]) {
|
|
1914
|
+
if (node === canvas || node.tagName === "CANVAS") continue;
|
|
1915
|
+
const saved = { op: node.style.getPropertyValue("opacity") };
|
|
1916
|
+
for (const p in INK) saved[p] = node.style.getPropertyValue(p);
|
|
1917
|
+
inkSaved.push([node, saved]);
|
|
1918
|
+
for (const p in INK) node.style.setProperty(p, INK[p], "important");
|
|
1919
|
+
// Replaced content has no "ink" property to neutralise.
|
|
1920
|
+
if (node !== el && node.matches && node.matches(REPLACED)) {
|
|
1921
|
+
node.style.setProperty("opacity", "0", "important");
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
// Contain the negative-z canvas so it stays behind this element's
|
|
1925
|
+
// text but never slips behind an ancestor's background.
|
|
1926
|
+
el.style.isolation = "isolate";
|
|
1927
|
+
canvas.style.zIndex = "-1";
|
|
1928
|
+
};
|
|
1929
|
+
|
|
1930
|
+
const showHostInk = () => {
|
|
1931
|
+
el.style.visibility = "";
|
|
1932
|
+
if (!inkSaved) return;
|
|
1933
|
+
for (const [node, saved] of inkSaved) {
|
|
1934
|
+
for (const p in INK) {
|
|
1935
|
+
if (saved[p]) node.style.setProperty(p, saved[p]);
|
|
1936
|
+
else node.style.removeProperty(p);
|
|
1937
|
+
}
|
|
1938
|
+
if (saved.op) node.style.setProperty("opacity", saved.op);
|
|
1939
|
+
else node.style.removeProperty("opacity");
|
|
1940
|
+
}
|
|
1941
|
+
inkSaved = null;
|
|
1942
|
+
el.style.isolation = "";
|
|
1943
|
+
canvas.style.zIndex = "";
|
|
1944
|
+
};
|
|
1945
|
+
|
|
1338
1946
|
const snapshotCapture = () => {
|
|
1339
1947
|
// Overlay + image content: use the image directly as the texture.
|
|
1340
1948
|
if (isOverlay) {
|
|
@@ -1359,8 +1967,7 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1359
1967
|
}
|
|
1360
1968
|
}
|
|
1361
1969
|
canvas.style.display = "none";
|
|
1362
|
-
|
|
1363
|
-
el.style.visibility = "";
|
|
1970
|
+
showHostInk();
|
|
1364
1971
|
const p = snapshotToImage(el, rect.width, rect.height, dpr)
|
|
1365
1972
|
.then((img) => {
|
|
1366
1973
|
if (destroyed) return;
|
|
@@ -1368,15 +1975,14 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1368
1975
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
|
1369
1976
|
finishUpload();
|
|
1370
1977
|
textureReady = true;
|
|
1371
|
-
//
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
|
|
1375
|
-
el.style.visibility = pureOverlay ? "" : "hidden";
|
|
1978
|
+
// A pure-overlay chain leaves the host alone; the lens
|
|
1979
|
+
// floats over untouched content. Otherwise the canvas
|
|
1980
|
+
// carries the look and the host's own ink is suppressed.
|
|
1981
|
+
if (!pureOverlay) hideHostInk();
|
|
1376
1982
|
canvas.style.visibility = "visible";
|
|
1377
1983
|
})
|
|
1378
1984
|
.catch((e) => {
|
|
1379
|
-
|
|
1985
|
+
showHostInk();
|
|
1380
1986
|
console.warn("[nodality] raster snapshot failed:", e);
|
|
1381
1987
|
});
|
|
1382
1988
|
canvas.style.display = "";
|
|
@@ -1582,20 +2188,25 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1582
2188
|
|
|
1583
2189
|
let ro = null;
|
|
1584
2190
|
let resizeTimer = 0;
|
|
2191
|
+
// Re-measure the host and capture again. Shared by the
|
|
2192
|
+
// ResizeObserver, the window-resize fallback and the font-loading
|
|
2193
|
+
// recapture, so all three agree on what "resize the canvas" means.
|
|
2194
|
+
const remeasureAndCapture = () => {
|
|
2195
|
+
if (destroyed || mode !== "snapshot") return;
|
|
2196
|
+
const m = measure();
|
|
2197
|
+
rect.width = m.width;
|
|
2198
|
+
rect.height = m.height;
|
|
2199
|
+
canvas.width = Math.round(m.width * dpr);
|
|
2200
|
+
canvas.height = Math.round(m.height * dpr);
|
|
2201
|
+
canvas.style.width = m.width + "px";
|
|
2202
|
+
canvas.style.height = m.height + "px";
|
|
2203
|
+
snapshotCapture();
|
|
2204
|
+
};
|
|
2205
|
+
|
|
1585
2206
|
if (typeof ResizeObserver !== "undefined") {
|
|
1586
2207
|
ro = new ResizeObserver(() => {
|
|
1587
2208
|
clearTimeout(resizeTimer);
|
|
1588
|
-
resizeTimer = setTimeout(
|
|
1589
|
-
if (destroyed || mode !== "snapshot") return;
|
|
1590
|
-
const m = measure();
|
|
1591
|
-
rect.width = m.width;
|
|
1592
|
-
rect.height = m.height;
|
|
1593
|
-
canvas.width = Math.round(m.width * dpr);
|
|
1594
|
-
canvas.height = Math.round(m.height * dpr);
|
|
1595
|
-
canvas.style.width = m.width + "px";
|
|
1596
|
-
canvas.style.height = m.height + "px";
|
|
1597
|
-
snapshotCapture();
|
|
1598
|
-
}, 150);
|
|
2209
|
+
resizeTimer = setTimeout(remeasureAndCapture, 150);
|
|
1599
2210
|
});
|
|
1600
2211
|
ro.observe(el);
|
|
1601
2212
|
}
|
|
@@ -1612,17 +2223,7 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1612
2223
|
clearTimeout(winResizeTimer);
|
|
1613
2224
|
winResizeTimer = setTimeout(() => {
|
|
1614
2225
|
if (destroyed) return;
|
|
1615
|
-
if (mode === "snapshot") {
|
|
1616
|
-
const m = measure();
|
|
1617
|
-
rect.width = m.width;
|
|
1618
|
-
rect.height = m.height;
|
|
1619
|
-
canvas.width = Math.round(m.width * dpr);
|
|
1620
|
-
canvas.height = Math.round(m.height * dpr);
|
|
1621
|
-
canvas.style.width = m.width + "px";
|
|
1622
|
-
canvas.style.height = m.height + "px";
|
|
1623
|
-
snapshotCapture();
|
|
1624
|
-
return;
|
|
1625
|
-
}
|
|
2226
|
+
if (mode === "snapshot") { remeasureAndCapture(); return; }
|
|
1626
2227
|
const w = el.clientWidth ||
|
|
1627
2228
|
(el.parentElement && el.parentElement.clientWidth) || rect.width;
|
|
1628
2229
|
canvas.style.width = w + "px";
|
|
@@ -1647,6 +2248,19 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1647
2248
|
raf = requestAnimationFrame(draw);
|
|
1648
2249
|
} else {
|
|
1649
2250
|
snapshotCapture().then(() => { raf = requestAnimationFrame(draw); });
|
|
2251
|
+
// A web font that arrives after the first capture changes glyph
|
|
2252
|
+
// metrics, and therefore line breaking: the snapshot would keep
|
|
2253
|
+
// whatever the fallback font wrapped to while the DOM underneath
|
|
2254
|
+
// reflows to something else. The element box does not necessarily
|
|
2255
|
+
// change, so the ResizeObserver never fires and the mismatch
|
|
2256
|
+
// persists until an unrelated resize. Capture once more when the
|
|
2257
|
+
// fonts settle.
|
|
2258
|
+
if (typeof document !== "undefined" && document.fonts &&
|
|
2259
|
+
typeof document.fonts.ready === "object") {
|
|
2260
|
+
Promise.resolve(document.fonts.ready)
|
|
2261
|
+
.then(remeasureAndCapture)
|
|
2262
|
+
.catch(() => {});
|
|
2263
|
+
}
|
|
1650
2264
|
}
|
|
1651
2265
|
|
|
1652
2266
|
return {
|
|
@@ -1671,6 +2285,7 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1671
2285
|
}
|
|
1672
2286
|
solverOps.length = 0;
|
|
1673
2287
|
while (sourceEl !== el && sourceEl.firstChild) el.appendChild(sourceEl.firstChild);
|
|
2288
|
+
showHostInk();
|
|
1674
2289
|
el.style.visibility = "";
|
|
1675
2290
|
canvas.remove();
|
|
1676
2291
|
},
|
|
@@ -1680,4 +2295,7 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1680
2295
|
export {
|
|
1681
2296
|
applyRasterPipeline, registerRasterOp, RASTER_OP_NAMES,
|
|
1682
2297
|
isHTMLInCanvasAvailable, DRIVER_NAMES,
|
|
2298
|
+
// Exported so which branch a `switch` takes can be asserted directly
|
|
2299
|
+
// rather than inferred from pixels.
|
|
2300
|
+
resolveSwitches,
|
|
1683
2301
|
};
|