@wardic/dom 0.0.0-stage → 1.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +8 -2
- package/THIRD_PARTY_NOTICES.md +145 -0
- package/dist/Appear.d.ts +24 -0
- package/dist/Appear.d.ts.map +1 -0
- package/dist/Appear.js +25 -0
- package/dist/Appear.js.map +1 -0
- package/dist/Color.d.ts +14 -0
- package/dist/Color.d.ts.map +1 -0
- package/dist/Color.js +16 -0
- package/dist/Color.js.map +1 -0
- package/dist/DomAnnouncer.d.ts +13 -0
- package/dist/DomAnnouncer.d.ts.map +1 -0
- package/dist/DomAnnouncer.js +14 -0
- package/dist/DomAnnouncer.js.map +1 -0
- package/dist/DomClock.d.ts +11 -0
- package/dist/DomClock.d.ts.map +1 -0
- package/dist/DomClock.js +12 -0
- package/dist/DomClock.js.map +1 -0
- package/dist/DomDefaultTransition.d.ts +12 -0
- package/dist/DomDefaultTransition.d.ts.map +1 -0
- package/dist/DomDefaultTransition.js +13 -0
- package/dist/DomDefaultTransition.js.map +1 -0
- package/dist/DomInput.d.ts +13 -0
- package/dist/DomInput.d.ts.map +1 -0
- package/dist/DomInput.js +15 -0
- package/dist/DomInput.js.map +1 -0
- package/dist/DomInterpolators.d.ts +13 -0
- package/dist/DomInterpolators.d.ts.map +1 -0
- package/dist/DomInterpolators.js +14 -0
- package/dist/DomInterpolators.js.map +1 -0
- package/dist/DomKeyRules.d.ts +13 -0
- package/dist/DomKeyRules.d.ts.map +1 -0
- package/dist/DomKeyRules.js +14 -0
- package/dist/DomKeyRules.js.map +1 -0
- package/dist/DomPositionalKeys.d.ts +13 -0
- package/dist/DomPositionalKeys.d.ts.map +1 -0
- package/dist/DomPositionalKeys.js +14 -0
- package/dist/DomPositionalKeys.js.map +1 -0
- package/dist/DomProbe.d.ts +13 -0
- package/dist/DomProbe.d.ts.map +1 -0
- package/dist/DomProbe.js +14 -0
- package/dist/DomProbe.js.map +1 -0
- package/dist/DomProjectionSystem.d.ts +28 -0
- package/dist/DomProjectionSystem.d.ts.map +1 -0
- package/dist/DomProjectionSystem.js +41 -0
- package/dist/DomProjectionSystem.js.map +1 -0
- package/dist/DomRenderer.d.ts +13 -0
- package/dist/DomRenderer.d.ts.map +1 -0
- package/dist/DomRenderer.js +15 -0
- package/dist/DomRenderer.js.map +1 -0
- package/dist/FlushGate.d.ts +48 -0
- package/dist/FlushGate.d.ts.map +1 -0
- package/dist/FlushGate.js +62 -0
- package/dist/FlushGate.js.map +1 -0
- package/dist/MeasuredPair.d.ts +14 -0
- package/dist/MeasuredPair.d.ts.map +1 -0
- package/dist/MeasuredPair.js +16 -0
- package/dist/MeasuredPair.js.map +1 -0
- package/dist/OwnValues.d.ts +14 -0
- package/dist/OwnValues.d.ts.map +1 -0
- package/dist/OwnValues.js +15 -0
- package/dist/OwnValues.js.map +1 -0
- package/dist/ProjectionSystem.d.ts +34 -0
- package/dist/ProjectionSystem.d.ts.map +1 -0
- package/dist/ProjectionSystem.js +29 -0
- package/dist/ProjectionSystem.js.map +1 -0
- package/dist/ReorderController.d.ts +41 -0
- package/dist/ReorderController.d.ts.map +1 -0
- package/dist/ReorderController.js +44 -0
- package/dist/ReorderController.js.map +1 -0
- package/dist/Report.d.ts +12 -0
- package/dist/Report.d.ts.map +1 -0
- package/dist/Report.js +13 -0
- package/dist/Report.js.map +1 -0
- package/dist/ScrollOffset.d.ts +34 -0
- package/dist/ScrollOffset.d.ts.map +1 -0
- package/dist/ScrollOffset.js +42 -0
- package/dist/ScrollOffset.js.map +1 -0
- package/dist/Style.d.ts +15 -0
- package/dist/Style.d.ts.map +1 -0
- package/dist/Style.js +17 -0
- package/dist/Style.js.map +1 -0
- package/dist/Svg.d.ts +13 -0
- package/dist/Svg.d.ts.map +1 -0
- package/dist/Svg.js +14 -0
- package/dist/Svg.js.map +1 -0
- package/dist/Variables.d.ts +13 -0
- package/dist/Variables.d.ts.map +1 -0
- package/dist/Variables.js +14 -0
- package/dist/Variables.js.map +1 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/announcer.d.ts +50 -0
- package/dist/internal/announcer.d.ts.map +1 -0
- package/dist/internal/announcer.js +115 -0
- package/dist/internal/announcer.js.map +1 -0
- package/dist/internal/appear.d.ts +169 -0
- package/dist/internal/appear.d.ts.map +1 -0
- package/dist/internal/appear.js +499 -0
- package/dist/internal/appear.js.map +1 -0
- package/dist/internal/auto-scroll.d.ts +24 -0
- package/dist/internal/auto-scroll.d.ts.map +1 -0
- package/dist/internal/auto-scroll.js +124 -0
- package/dist/internal/auto-scroll.js.map +1 -0
- package/dist/internal/clock.d.ts +19 -0
- package/dist/internal/clock.d.ts.map +1 -0
- package/dist/internal/clock.js +39 -0
- package/dist/internal/clock.js.map +1 -0
- package/dist/internal/colors.d.ts +45 -0
- package/dist/internal/colors.d.ts.map +1 -0
- package/dist/internal/colors.js +193 -0
- package/dist/internal/colors.js.map +1 -0
- package/dist/internal/complex.d.ts +24 -0
- package/dist/internal/complex.d.ts.map +1 -0
- package/dist/internal/complex.js +28 -0
- package/dist/internal/complex.js.map +1 -0
- package/dist/internal/content-watch.d.ts +20 -0
- package/dist/internal/content-watch.d.ts.map +1 -0
- package/dist/internal/content-watch.js +76 -0
- package/dist/internal/content-watch.js.map +1 -0
- package/dist/internal/default-transition.d.ts +25 -0
- package/dist/internal/default-transition.d.ts.map +1 -0
- package/dist/internal/default-transition.js +41 -0
- package/dist/internal/default-transition.js.map +1 -0
- package/dist/internal/drag-defaults.d.ts +18 -0
- package/dist/internal/drag-defaults.d.ts.map +1 -0
- package/dist/internal/drag-defaults.js +38 -0
- package/dist/internal/drag-defaults.js.map +1 -0
- package/dist/internal/gate.d.ts +186 -0
- package/dist/internal/gate.d.ts.map +1 -0
- package/dist/internal/gate.js +453 -0
- package/dist/internal/gate.js.map +1 -0
- package/dist/internal/input.d.ts +41 -0
- package/dist/internal/input.d.ts.map +1 -0
- package/dist/internal/input.js +476 -0
- package/dist/internal/input.js.map +1 -0
- package/dist/internal/interpolators.d.ts +19 -0
- package/dist/internal/interpolators.d.ts.map +1 -0
- package/dist/internal/interpolators.js +30 -0
- package/dist/internal/interpolators.js.map +1 -0
- package/dist/internal/key-rules.d.ts +11 -0
- package/dist/internal/key-rules.d.ts.map +1 -0
- package/dist/internal/key-rules.js +244 -0
- package/dist/internal/key-rules.js.map +1 -0
- package/dist/internal/measure.d.ts +150 -0
- package/dist/internal/measure.d.ts.map +1 -0
- package/dist/internal/measure.js +278 -0
- package/dist/internal/measure.js.map +1 -0
- package/dist/internal/measured-pairs.d.ts +38 -0
- package/dist/internal/measured-pairs.d.ts.map +1 -0
- package/dist/internal/measured-pairs.js +251 -0
- package/dist/internal/measured-pairs.js.map +1 -0
- package/dist/internal/own-values.d.ts +35 -0
- package/dist/internal/own-values.d.ts.map +1 -0
- package/dist/internal/own-values.js +117 -0
- package/dist/internal/own-values.js.map +1 -0
- package/dist/internal/pass-ends.d.ts +21 -0
- package/dist/internal/pass-ends.d.ts.map +1 -0
- package/dist/internal/pass-ends.js +14 -0
- package/dist/internal/pass-ends.js.map +1 -0
- package/dist/internal/positional-keys.d.ts +16 -0
- package/dist/internal/positional-keys.d.ts.map +1 -0
- package/dist/internal/positional-keys.js +21 -0
- package/dist/internal/positional-keys.js.map +1 -0
- package/dist/internal/probe.d.ts +10 -0
- package/dist/internal/probe.d.ts.map +1 -0
- package/dist/internal/probe.js +117 -0
- package/dist/internal/probe.js.map +1 -0
- package/dist/internal/projection-defaults.d.ts +34 -0
- package/dist/internal/projection-defaults.d.ts.map +1 -0
- package/dist/internal/projection-defaults.js +21 -0
- package/dist/internal/projection-defaults.js.map +1 -0
- package/dist/internal/projection.d.ts +529 -0
- package/dist/internal/projection.d.ts.map +1 -0
- package/dist/internal/projection.js +4990 -0
- package/dist/internal/projection.js.map +1 -0
- package/dist/internal/renderer.d.ts +86 -0
- package/dist/internal/renderer.d.ts.map +1 -0
- package/dist/internal/renderer.js +616 -0
- package/dist/internal/renderer.js.map +1 -0
- package/dist/internal/reorder.d.ts +159 -0
- package/dist/internal/reorder.d.ts.map +1 -0
- package/dist/internal/reorder.js +437 -0
- package/dist/internal/reorder.js.map +1 -0
- package/dist/internal/report.d.ts +30 -0
- package/dist/internal/report.d.ts.map +1 -0
- package/dist/internal/report.js +60 -0
- package/dist/internal/report.js.map +1 -0
- package/dist/internal/require-global.d.ts +11 -0
- package/dist/internal/require-global.d.ts.map +1 -0
- package/dist/internal/require-global.js +31 -0
- package/dist/internal/require-global.js.map +1 -0
- package/dist/internal/reveal.d.ts +16 -0
- package/dist/internal/reveal.d.ts.map +1 -0
- package/dist/internal/reveal.js +66 -0
- package/dist/internal/reveal.js.map +1 -0
- package/dist/internal/runner.d.ts +21 -0
- package/dist/internal/runner.d.ts.map +1 -0
- package/dist/internal/runner.js +48 -0
- package/dist/internal/runner.js.map +1 -0
- package/dist/internal/scale-correction.d.ts +80 -0
- package/dist/internal/scale-correction.d.ts.map +1 -0
- package/dist/internal/scale-correction.js +227 -0
- package/dist/internal/scale-correction.js.map +1 -0
- package/dist/internal/scroll.d.ts +35 -0
- package/dist/internal/scroll.d.ts.map +1 -0
- package/dist/internal/scroll.js +244 -0
- package/dist/internal/scroll.js.map +1 -0
- package/dist/internal/scrollers.d.ts +35 -0
- package/dist/internal/scrollers.d.ts.map +1 -0
- package/dist/internal/scrollers.js +38 -0
- package/dist/internal/scrollers.js.map +1 -0
- package/dist/internal/starting-style.d.ts +141 -0
- package/dist/internal/starting-style.d.ts.map +1 -0
- package/dist/internal/starting-style.js +300 -0
- package/dist/internal/starting-style.js.map +1 -0
- package/dist/internal/svg.d.ts +60 -0
- package/dist/internal/svg.d.ts.map +1 -0
- package/dist/internal/svg.js +257 -0
- package/dist/internal/svg.js.map +1 -0
- package/dist/internal/variables.d.ts +45 -0
- package/dist/internal/variables.d.ts.map +1 -0
- package/dist/internal/variables.js +92 -0
- package/dist/internal/variables.js.map +1 -0
- package/package.json +65 -4
- package/src/Appear.ts +45 -0
- package/src/Color.ts +24 -0
- package/src/DomAnnouncer.ts +22 -0
- package/src/DomClock.ts +13 -0
- package/src/DomDefaultTransition.ts +16 -0
- package/src/DomInput.ts +22 -0
- package/src/DomInterpolators.ts +16 -0
- package/src/DomKeyRules.ts +17 -0
- package/src/DomPositionalKeys.ts +16 -0
- package/src/DomProbe.ts +15 -0
- package/src/DomProjectionSystem.ts +44 -0
- package/src/DomRenderer.ts +26 -0
- package/src/FlushGate.ts +81 -0
- package/src/MeasuredPair.ts +15 -0
- package/src/OwnValues.ts +19 -0
- package/src/ProjectionSystem.ts +51 -0
- package/src/ReorderController.ts +72 -0
- package/src/Report.ts +17 -0
- package/src/ScrollOffset.ts +54 -0
- package/src/Style.ts +37 -0
- package/src/Svg.ts +21 -0
- package/src/Variables.ts +17 -0
- package/src/index.ts +106 -0
- package/src/internal/announcer.ts +132 -0
- package/src/internal/appear.ts +739 -0
- package/src/internal/auto-scroll.ts +176 -0
- package/src/internal/clock.ts +45 -0
- package/src/internal/colors.ts +258 -0
- package/src/internal/complex.ts +29 -0
- package/src/internal/content-watch.ts +84 -0
- package/src/internal/culori.d.ts +58 -0
- package/src/internal/default-transition.ts +50 -0
- package/src/internal/drag-defaults.ts +54 -0
- package/src/internal/gate.ts +593 -0
- package/src/internal/input.ts +579 -0
- package/src/internal/interpolators.ts +33 -0
- package/src/internal/key-rules.ts +357 -0
- package/src/internal/measure.ts +357 -0
- package/src/internal/measured-pairs.ts +297 -0
- package/src/internal/own-values.ts +166 -0
- package/src/internal/pass-ends.ts +23 -0
- package/src/internal/positional-keys.ts +24 -0
- package/src/internal/probe.ts +176 -0
- package/src/internal/projection-defaults.ts +45 -0
- package/src/internal/projection.ts +5999 -0
- package/src/internal/renderer.ts +646 -0
- package/src/internal/reorder.ts +604 -0
- package/src/internal/report.ts +69 -0
- package/src/internal/require-global.ts +46 -0
- package/src/internal/reveal.ts +69 -0
- package/src/internal/runner.ts +64 -0
- package/src/internal/scale-correction.ts +310 -0
- package/src/internal/scroll.ts +356 -0
- package/src/internal/scrollers.ts +51 -0
- package/src/internal/starting-style.ts +396 -0
- package/src/internal/svg.ts +300 -0
- package/src/internal/variables.ts +145 -0
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Appear animations (#48, ADR-0044): a server-rendered mount animation played in CSS from first
|
|
3
|
+
* paint, which the client takes over at mount (the **Handoff**). The DOM's half, framework-free:
|
|
4
|
+
*
|
|
5
|
+
* - **The sampler** steps a binding's mount animation through core's machines, as a 60 Hz client
|
|
6
|
+
* would (`Machine.CATCH_UP_DT`), projects each Frame with the DOM's `project`, and writes it as one
|
|
7
|
+
* `@keyframes` rule, thinned to the stops a straight line can't stand in for, within a tolerance
|
|
8
|
+
* per key below what a screen shows (decision 9). One rule a spec, cached across requests by it.
|
|
9
|
+
* - **A sheet** collects one render's rules. An element's inline style names only a custom
|
|
10
|
+
* property, `animation: var(--wardic-aN)`, which the sheet defines in `:root` once the render
|
|
11
|
+
* knows its timing (decision 5); undefined, it leaves the element at its Starting style.
|
|
12
|
+
* - **The Handoff's reads:** the element's `wardic-…` animation, how far it has got, and its
|
|
13
|
+
* removal, written in the same task as the engine's first frame (decision 6).
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
import * as BaseValue from "@wardic/core/BaseValue"
|
|
18
|
+
import type * as Frame from "@wardic/core/Frame"
|
|
19
|
+
import * as Machine from "@wardic/core/Machine"
|
|
20
|
+
import * as Projection from "@wardic/core/Projection"
|
|
21
|
+
import type * as Values from "@wardic/core/Values"
|
|
22
|
+
import * as Arr from "effect/Array"
|
|
23
|
+
import * as Bool from "effect/Boolean"
|
|
24
|
+
import type { LazyArg } from "effect/Function"
|
|
25
|
+
import * as Option from "effect/Option"
|
|
26
|
+
import * as Order from "effect/Order"
|
|
27
|
+
import * as Predicate from "effect/Predicate"
|
|
28
|
+
import * as Rec from "effect/Record"
|
|
29
|
+
import * as Result from "effect/Result"
|
|
30
|
+
import * as UndefinedOr from "effect/UndefinedOr"
|
|
31
|
+
import { spacesRegistered } from "./colors.ts"
|
|
32
|
+
import { drawnUnitOf, project, TEMPLATE_KEY, type TransformTemplate } from "./renderer.ts"
|
|
33
|
+
import { dashCase, isPathKey, isSvgInnerTag, splitPatchForTag, svgAttrNameOf } from "./svg.ts"
|
|
34
|
+
|
|
35
|
+
/** An element's mount animation, as its binding describes it. */
|
|
36
|
+
export interface Spec {
|
|
37
|
+
/**
|
|
38
|
+
* What built the animation, as one string the binding writes the same on the server and the
|
|
39
|
+
* client: its starting values, its targets, its transition, whether motion is reduced. The rule's
|
|
40
|
+
* name is its hash, so a client tells a Handoff it can continue from one it can't (decision 6).
|
|
41
|
+
*/
|
|
42
|
+
readonly key: string
|
|
43
|
+
/** The element's tag, so an SVG element's keys go to its attributes; `undefined` for an HTML element. */
|
|
44
|
+
// ADR-0014.
|
|
45
|
+
readonly tag: string | undefined
|
|
46
|
+
/**
|
|
47
|
+
* The animation's values from its start (for an `infinite` one, one cycle of it), built only when
|
|
48
|
+
* the rule isn't cached: a server render calls it once per spec per process.
|
|
49
|
+
*/
|
|
50
|
+
readonly machine: LazyArg<Machine.Machine<Frame.Frame<Values.Values>>>
|
|
51
|
+
/** Played over and over: an infinite repeat, `machine` one cycle of it. */
|
|
52
|
+
readonly infinite?: boolean | undefined
|
|
53
|
+
/** A layout node: drawn through its projection at rest, as the client draws it, its own move on top. */
|
|
54
|
+
// ADR-0009, #245; its own translate (#363).
|
|
55
|
+
readonly projected?: boolean | undefined
|
|
56
|
+
/**
|
|
57
|
+
* The element's transform template: each stop's transform drawn through it, as the client draws it. A
|
|
58
|
+
* function's text can't name a rule, so a templated spec's rule is named by its stops, and sampled for
|
|
59
|
+
* each element that has one, never cached.
|
|
60
|
+
*/
|
|
61
|
+
// A **Transform template** (#443).
|
|
62
|
+
readonly template?: TransformTemplate | undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A spec's `@keyframes` rule. */
|
|
66
|
+
export interface Keyframes {
|
|
67
|
+
/** The rule's name: `wardic-` and the spec's hash. */
|
|
68
|
+
readonly name: string
|
|
69
|
+
/**
|
|
70
|
+
* Seconds the rule plays, a multiple of 1/60 s: to the first Frame from which every one is within
|
|
71
|
+
* tolerance of the settled one.
|
|
72
|
+
*/
|
|
73
|
+
readonly duration: number
|
|
74
|
+
/** Seconds to the Frame the spec settles at, which a client waits for: at or past `duration`. */
|
|
75
|
+
// `when` (ADR-0007).
|
|
76
|
+
readonly settled: number
|
|
77
|
+
/** Whether it plays over and over. */
|
|
78
|
+
readonly infinite: boolean
|
|
79
|
+
/** The time of each stop kept, in seconds from the start. */
|
|
80
|
+
readonly times: ReadonlyArray<number>
|
|
81
|
+
/** The rule, `@keyframes <name>{…}`. */
|
|
82
|
+
readonly css: string
|
|
83
|
+
/**
|
|
84
|
+
* The SVG geometry properties its stops write (`cx`, `r`…), which some browsers don't draw from CSS: an
|
|
85
|
+
* element playing it is defined under `@supports` for each, so a browser without them keeps it at its
|
|
86
|
+
* starting style. Empty for most rules.
|
|
87
|
+
*/
|
|
88
|
+
// #245.
|
|
89
|
+
readonly supports: ReadonlyArray<string>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Where an element's timing is decided: a spec, and the delay it starts after. */
|
|
93
|
+
export interface Play {
|
|
94
|
+
/** The element's mount animation. */
|
|
95
|
+
readonly spec: Spec
|
|
96
|
+
/** Seconds before it starts: its orchestration's, and any delay on the whole element. */
|
|
97
|
+
readonly delay: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The prefix every Appear rule's name starts with. */
|
|
101
|
+
export const PREFIX = "wardic-"
|
|
102
|
+
|
|
103
|
+
/** The longest mount animation sampled, in seconds; a longer one keeps its Starting style. */
|
|
104
|
+
export const MAX_SECONDS = 60
|
|
105
|
+
|
|
106
|
+
const MAX_STEPS = Math.round(MAX_SECONDS / Machine.CATCH_UP_DT)
|
|
107
|
+
|
|
108
|
+
// --- The spec's name ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/** cyrb53, a 53-bit string hash: plenty for a page's specs, and the same in every engine. */
|
|
111
|
+
const hash53 = (text: string): string => {
|
|
112
|
+
const [h1, h2] = Arr.reduce(Array.from(text), [0xdeadbeef, 0x41c6ce57] as const, ([a, b], char): readonly [number, number] => {
|
|
113
|
+
const code = char.codePointAt(0) as number
|
|
114
|
+
return [Math.imul(a ^ code, 2654435761), Math.imul(b ^ code, 1597334677)]
|
|
115
|
+
})
|
|
116
|
+
const a = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
|
|
117
|
+
const b = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
|
|
118
|
+
return (4294967296 * (2097151 & b) + (a >>> 0)).toString(36)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Each spec's name by its text, so a page's elements sharing a spec hash it once; the oldest dropped past `NAMES_SIZE`. */
|
|
122
|
+
// Module state (#460, Q7): a cache keyed by what it caches: a bounded set of names.
|
|
123
|
+
const names = new Map<string, string>()
|
|
124
|
+
|
|
125
|
+
const NAMES_SIZE = 4096
|
|
126
|
+
|
|
127
|
+
/** The rule's name for a spec: its key, tag, repeat and projection, hashed. */
|
|
128
|
+
// Once a text: a server names each element's spec.
|
|
129
|
+
export const nameOf = (spec: Pick<Spec, "key" | "tag" | "infinite" | "projected">): string => {
|
|
130
|
+
const text = specTextOf(spec)
|
|
131
|
+
return Option.getOrElse(Option.fromUndefinedOr(names.get(text)), () => {
|
|
132
|
+
const name = `${PREFIX}${hash53(text)}`
|
|
133
|
+
if (names.size >= NAMES_SIZE) names.clear()
|
|
134
|
+
names.set(text, name)
|
|
135
|
+
return name
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A spec's text, which its rule's name hashes: its tag, repeat, key and projection. */
|
|
140
|
+
const specTextOf = (spec: Pick<Spec, "key" | "tag" | "infinite" | "projected">): string =>
|
|
141
|
+
`${UndefinedOr.match(spec.tag, { onUndefined: () => "", onDefined: (tag: string) => tag })}|${spec.infinite === true}|${spec.key}${
|
|
142
|
+
Bool.match(spec.projected === true, { onTrue: () => "|projected", onFalse: () => "" })
|
|
143
|
+
}`
|
|
144
|
+
|
|
145
|
+
// --- Sampling ----------------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The spec's Frames' values at 0, 1/60 s, 2/60 s… to the settled one: `startAt`'s catch-up steps,
|
|
149
|
+
* so the client's `startAt(k / 60)` draws the `k`th. Each copied, since a machine may rewrite its
|
|
150
|
+
* Frame in place (`springValues`, ADR-0030 decision 11). `None` past `MAX_SECONDS`.
|
|
151
|
+
*/
|
|
152
|
+
const framesOf = (machine: Machine.Machine<Frame.Frame<Values.Values>>): Option.Option<ReadonlyArray<Values.Values>> => {
|
|
153
|
+
const step = machine.start()
|
|
154
|
+
const copy = (frame: Frame.Frame<Values.Values>) => ({ values: { ...frame.value }, done: frame.done })
|
|
155
|
+
// Copied before the next step, which may rewrite it.
|
|
156
|
+
const first = copy(step(0))
|
|
157
|
+
const rest = Arr.unfold({ frame: first, steps: 0 }, ({ frame, steps }) =>
|
|
158
|
+
Bool.match(frame.done || steps >= MAX_STEPS, {
|
|
159
|
+
onTrue: () => Option.none(),
|
|
160
|
+
onFalse: () => {
|
|
161
|
+
const next = copy(step(Machine.CATCH_UP_DT))
|
|
162
|
+
return Option.some([next, { frame: next, steps: steps + 1 }] as const)
|
|
163
|
+
},
|
|
164
|
+
}))
|
|
165
|
+
const frames = Arr.prepend(rest, first)
|
|
166
|
+
return Option.liftPredicate(
|
|
167
|
+
Arr.map(frames, (frame) => frame.values),
|
|
168
|
+
() => Arr.lastNonEmpty(frames).done,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** One key's value split for comparing: its numbers, and the text between them. */
|
|
173
|
+
interface Split {
|
|
174
|
+
readonly skeleton: string
|
|
175
|
+
readonly numbers: ReadonlyArray<number>
|
|
176
|
+
/** The decimals each number was written with: a quantised token can't be judged finer than it. */
|
|
177
|
+
readonly decimals: ReadonlyArray<number>
|
|
178
|
+
/** Each number's unit, the letters after it. */
|
|
179
|
+
readonly units: ReadonlyArray<string>
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const NUMBER_TOKEN = /(-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)([a-z%]*)/gi
|
|
183
|
+
|
|
184
|
+
/** A value split into its numbers and the text around them; a number is one token with no text. */
|
|
185
|
+
const splitOf = (value: number | string): Split =>
|
|
186
|
+
Bool.match(Predicate.isNumber(value), {
|
|
187
|
+
onTrue: () => ({ skeleton: "#", numbers: [value as number], decimals: [Number.POSITIVE_INFINITY], units: [""] }),
|
|
188
|
+
onFalse: () => {
|
|
189
|
+
const text = String(value)
|
|
190
|
+
const tokens = Array.from(text.matchAll(NUMBER_TOKEN))
|
|
191
|
+
return {
|
|
192
|
+
skeleton: text.replace(NUMBER_TOKEN, "#$2"),
|
|
193
|
+
numbers: Arr.map(tokens, (match) => Number(match[1])),
|
|
194
|
+
decimals: Arr.map(tokens, (match) => decimalsOf(String(match[1]))),
|
|
195
|
+
units: Arr.map(tokens, (match) => String(match[2]).toLowerCase()),
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
/** How many decimals `token` is written with: none for `12`, two for `0.53`. */
|
|
201
|
+
const decimalsOf = (token: string): number =>
|
|
202
|
+
Option.match(Option.fromNullishOr(/\.(\d+)/.exec(token)), { onNone: () => 0, onSome: (match) => String(match[1]).length })
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The tolerance per key and token (decision 9): what a straight line between two stops may miss a
|
|
206
|
+
* sample by. A bare number by its key's unit: 0.1 px, 0.1°, 0.001 of a scale, 0.002 for the rest
|
|
207
|
+
* (opacity). A token in a string by its own unit, `px` and `deg` alike; any other by its part of
|
|
208
|
+
* its range, 1/500 (a colour channel's half step), and never finer than the decimals it's written
|
|
209
|
+
* with.
|
|
210
|
+
*/
|
|
211
|
+
const tolerancesOf = (key: string, svgInner: boolean, series: ReadonlyArray<Split>): ReadonlyArray<number> =>
|
|
212
|
+
Arr.map(Arr.headNonEmpty(series as Arr.NonEmptyReadonlyArray<Split>).numbers, (_, token) => {
|
|
213
|
+
const values = Arr.map(series, (split) => split.numbers[token] as number)
|
|
214
|
+
const span = Math.max(...values) - Math.min(...values)
|
|
215
|
+
const decimals = Math.min(6, Math.max(...Arr.map(series, (split) => split.decimals[token] as number)))
|
|
216
|
+
const unit = Arr.headNonEmpty(series as Arr.NonEmptyReadonlyArray<Split>).units[token] as string
|
|
217
|
+
const bare = Arr.headNonEmpty(series as Arr.NonEmptyReadonlyArray<Split>).skeleton === "#"
|
|
218
|
+
return Math.max(1e-9, toleranceFor(key, svgInner, bare, unit, span, 10 ** -decimals))
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const toleranceFor = (key: string, svgInner: boolean, bare: boolean, unit: string, span: number, quantum: number): number =>
|
|
222
|
+
Bool.match(bare, {
|
|
223
|
+
onTrue: () => Bool.match(svgInner, { onTrue: () => svgTolerance(key), onFalse: () => bareTolerance(key) }),
|
|
224
|
+
onFalse: () =>
|
|
225
|
+
Bool.match(unit === "px" || unit === "deg", {
|
|
226
|
+
onTrue: () => Math.max(0.1, quantum),
|
|
227
|
+
onFalse: () => Math.max(span / 500, quantum),
|
|
228
|
+
}),
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
/** A bare number's tolerance, by the unit `project` draws `key` in. */
|
|
232
|
+
const bareTolerance = (key: string): number =>
|
|
233
|
+
Bool.match(drawnUnitOf(key) === "", {
|
|
234
|
+
onTrue: () => Bool.match(BaseValue.TRANSFORM_IDENTITY[key] === 1, { onTrue: () => 0.001, onFalse: () => 0.002 }),
|
|
235
|
+
onFalse: () => 0.1,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
/** The opacities an svg-inner element draws as attributes, judged as opacity is. */
|
|
239
|
+
const SVG_OPACITIES: ReadonlySet<string> = new Set(["opacity", "fillOpacity", "strokeOpacity", "stopOpacity", "floodOpacity"])
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A bare number's tolerance on an svg-inner element (#245), by what its attribute means: a
|
|
243
|
+
* normalised dash (`pathLength`, `pathOffset`, `pathSpacing`: 0 to 1 of the path) 0.001, an
|
|
244
|
+
* opacity 0.002, any other attribute in user units, a CSS px, 0.1. A key drawn as style (the
|
|
245
|
+
* transform's) as on HTML.
|
|
246
|
+
*/
|
|
247
|
+
const svgTolerance = (key: string): number =>
|
|
248
|
+
Bool.match(isPathKey(key), {
|
|
249
|
+
onTrue: () => 0.001,
|
|
250
|
+
onFalse: () =>
|
|
251
|
+
Option.match(svgAttrNameOf(key), {
|
|
252
|
+
onNone: () => bareTolerance(key),
|
|
253
|
+
onSome: () => Bool.match(SVG_OPACITIES.has(key), { onTrue: () => 0.002, onFalse: () => 0.1 }),
|
|
254
|
+
}),
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
/** A series of Frames split per key, with each key's tolerances: what the stops are judged on. */
|
|
258
|
+
const judged = (frames: ReadonlyArray<Values.Values>, svgInner: boolean) => {
|
|
259
|
+
const keys = Arr.flatMap(Arr.take(frames, 1), Rec.keys)
|
|
260
|
+
const splits = Arr.map(keys, (key) => Arr.map(frames, (frame) => splitOf(UndefinedOr.match(frame[key], { onUndefined: () => "", onDefined: (v: number | string) => v }))))
|
|
261
|
+
const tolerances = Arr.map(keys, (key, k) => tolerancesOf(key, svgInner, splits[k] as ReadonlyArray<Split>))
|
|
262
|
+
/** Whether stop `i` is within every key's tolerance of where a straight line from `a` to `b` puts it. */
|
|
263
|
+
const onLine = (i: number, a: number, b: number): boolean =>
|
|
264
|
+
Arr.every(splits, (series, k) => {
|
|
265
|
+
const from = series[a] as Split
|
|
266
|
+
const to = series[b] as Split
|
|
267
|
+
const at = series[i] as Split
|
|
268
|
+
return at.skeleton === from.skeleton && at.skeleton === to.skeleton &&
|
|
269
|
+
Arr.every(at.numbers, (value, token) => {
|
|
270
|
+
const line = (from.numbers[token] as number) + (((to.numbers[token] as number) - (from.numbers[token] as number)) * (i - a)) / Math.max(1, b - a)
|
|
271
|
+
return Math.abs(value - line) <= ((tolerances[k] as ReadonlyArray<number>)[token] as number)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
/**
|
|
275
|
+
* Whether the Frame at `k` jumps from the one before: a key's text changes (a discrete value), or
|
|
276
|
+
* one of its numbers moves past its tolerance and four times its moves on either side (a loop's
|
|
277
|
+
* seam, a `steps()` easing, a snap). The engine draws one Frame, then the other; a line between
|
|
278
|
+
* them would draw values it never does.
|
|
279
|
+
*/
|
|
280
|
+
const jumpAt = (k: number): boolean =>
|
|
281
|
+
Arr.some(splits, (series, j) => {
|
|
282
|
+
const before = series[k - 1] as Split
|
|
283
|
+
const at = series[k] as Split
|
|
284
|
+
return Bool.or(
|
|
285
|
+
at.skeleton !== before.skeleton,
|
|
286
|
+
Arr.some(at.numbers, (value, token) => {
|
|
287
|
+
const move = (i: number): number =>
|
|
288
|
+
Option.getOrElse(
|
|
289
|
+
Option.map(Option.all([Option.fromUndefinedOr(series[i]), Option.fromUndefinedOr(series[i - 1])]), ([a, b]) => Math.abs((a.numbers[token] as number) - (b.numbers[token] as number))),
|
|
290
|
+
() => 0,
|
|
291
|
+
)
|
|
292
|
+
const step = Math.abs(value - (before.numbers[token] as number))
|
|
293
|
+
return Bool.and(step > ((tolerances[j] as ReadonlyArray<number>)[token] as number), step > 4 * Math.max(move(k - 1), move(k + 1)))
|
|
294
|
+
}),
|
|
295
|
+
)
|
|
296
|
+
})
|
|
297
|
+
return { onLine, jumpAt }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The stops kept (decision 9), as indices into `frames`, and where the rule ends.
|
|
302
|
+
*
|
|
303
|
+
* - **It ends** at the first Frame from which every one is within tolerance of the settled Frame,
|
|
304
|
+
* drawn as the settled Frame: a tween stepped in 1/60 s settles a frame after it arrives, when
|
|
305
|
+
* its steps' sum crosses its duration.
|
|
306
|
+
* - **The stops kept** are the first, the end, and each one a straight line from the last kept can't
|
|
307
|
+
* reach past, within every key's tolerances.
|
|
308
|
+
* - **A jump** (`jumps`, kept stops): a Frame the one before it can't be drawn into, as `jumpAt`
|
|
309
|
+
* tells. The rule holds the Frame before it to halfway between them, then steps, so a display
|
|
310
|
+
* frame near either draws that Frame, not a value on a line through values the engine never draws.
|
|
311
|
+
*/
|
|
312
|
+
export const thin = (
|
|
313
|
+
frames: ReadonlyArray<Values.Values>,
|
|
314
|
+
tag?: string | undefined,
|
|
315
|
+
): { readonly kept: ReadonlyArray<number>; readonly end: number; readonly jumps: ReadonlyArray<number> } => {
|
|
316
|
+
const last = frames.length - 1
|
|
317
|
+
const svgInner = Predicate.isNotUndefined(tag) && isSvgInnerTag(tag)
|
|
318
|
+
const whole = judged(frames, svgInner)
|
|
319
|
+
const end = Option.match(Arr.findLastIndex(Arr.range(0, last), (i) => !whole.onLine(i, last, last)), {
|
|
320
|
+
onNone: () => 0,
|
|
321
|
+
onSome: (i) => Math.min(last, i + 1),
|
|
322
|
+
})
|
|
323
|
+
// The series drawn: the Frames up to the end, the end drawn as the settled Frame.
|
|
324
|
+
const { onLine, jumpAt } = judged(Arr.append(Arr.take(frames, end), frames[last] as Values.Values), svgInner)
|
|
325
|
+
/** Whether one straight line from stop `a` to stop `b` stands in for every stop between. */
|
|
326
|
+
const fits = (a: number, b: number): boolean => Arr.every(Arr.range(a + 1, b - 1), (i) => onLine(i, a, b))
|
|
327
|
+
/** The farthest stop a line from `a` can reach. */
|
|
328
|
+
const reach = (a: number): number =>
|
|
329
|
+
Option.getOrElse(Arr.findFirst(Arr.range(a + 1, end), (b) => b === end || !fits(a, b + 1)), () => end)
|
|
330
|
+
const kept = Arr.unfold(0, (a) => Bool.match(a >= end, { onTrue: () => Option.none(), onFalse: () => Option.some([reach(a), reach(a)] as const) }))
|
|
331
|
+
return { kept: [0, ...kept], end, jumps: Arr.filter(kept, jumpAt) }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** A number written to at most four decimals, `-0` as `0`: finer than any tolerance, and short. */
|
|
335
|
+
const trimNumbers = (text: string): string =>
|
|
336
|
+
text.replace(/-?(?:\d+\.\d+|\.\d+)(?:e[-+]?\d+)?|-?\d+e[-+]?\d+/gi, (token) => String(Number(Number(token).toFixed(4)) + 0))
|
|
337
|
+
|
|
338
|
+
/** Text CSS can't hold inside a declaration, or that would end the `<style>` it sits in. */
|
|
339
|
+
const UNSAFE = /[<>{};]|\/\*|\\/
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The SVG attributes a keyframe draws as their CSS property (#245, ADR-0044 *Amendment (#245)*):
|
|
343
|
+
* presentation attributes, which a stylesheet's or an animation's value overrides, so the attribute
|
|
344
|
+
* the engine writes at the Handoff takes over value for value. Unitless, as the attribute is.
|
|
345
|
+
*/
|
|
346
|
+
const SVG_PRESENTATION: ReadonlySet<string> = new Set([
|
|
347
|
+
"opacity",
|
|
348
|
+
"fill",
|
|
349
|
+
"stroke",
|
|
350
|
+
"stroke-width",
|
|
351
|
+
"fill-opacity",
|
|
352
|
+
"stroke-opacity",
|
|
353
|
+
"stroke-dasharray",
|
|
354
|
+
"stroke-dashoffset",
|
|
355
|
+
"stop-color",
|
|
356
|
+
"stop-opacity",
|
|
357
|
+
"flood-color",
|
|
358
|
+
"flood-opacity",
|
|
359
|
+
])
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* SVG 2's geometry properties a keyframe draws, in px, behind `@supports` (`Keyframes.supports`):
|
|
363
|
+
* Chrome and Firefox draw them from CSS, Safari only in part. Not `width` and `height`:
|
|
364
|
+
* `@supports (width: 0px)` holds for HTML in every browser, so it can't tell whether one draws a
|
|
365
|
+
* `<rect>`'s from CSS.
|
|
366
|
+
*/
|
|
367
|
+
const SVG_GEOMETRY: ReadonlySet<string> = new Set(["cx", "cy", "r", "rx", "ry", "x", "y"])
|
|
368
|
+
|
|
369
|
+
/** The attribute the stroke dash writes beside it, `pathLength="1"`: the Starting style writes it too, and it never changes. */
|
|
370
|
+
const SVG_FIXED: ReadonlySet<string> = new Set(["pathLength"])
|
|
371
|
+
|
|
372
|
+
const BARE_NUMBER = /^-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i
|
|
373
|
+
|
|
374
|
+
/** An SVG attribute's declaration: a geometry property's bare number in px, what a user unit is. */
|
|
375
|
+
const attributeDeclaration = ([name, value]: readonly [string, string]): string =>
|
|
376
|
+
`${name}:${trimNumbers(Bool.match(Bool.and(SVG_GEOMETRY.has(name), BARE_NUMBER.test(value)), { onTrue: () => `${value}px`, onFalse: () => value }))}`
|
|
377
|
+
|
|
378
|
+
/** Why an attribute can't be a keyframe's: it isn't a CSS property (`d` in Safari, `points`, a filter primitive's). */
|
|
379
|
+
const drawnOnlyAsAttribute = (name: string): boolean => Bool.not(Bool.or(SVG_FIXED.has(name), Bool.or(SVG_PRESENTATION.has(name), SVG_GEOMETRY.has(name))))
|
|
380
|
+
|
|
381
|
+
/** One Frame's declarations and the geometry properties among them. */
|
|
382
|
+
interface Declarations {
|
|
383
|
+
readonly text: string
|
|
384
|
+
readonly supports: ReadonlyArray<string>
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* One Frame's declarations, or why it can't be one: a key SVG draws only as an attribute, or unsafe
|
|
389
|
+
* text. An svg-inner element's attributes are drawn as their CSS properties where they have one; a
|
|
390
|
+
* layout node through its projection at rest (#245).
|
|
391
|
+
*/
|
|
392
|
+
const declarationsOf = (tag: string | undefined, values: Values.Values, projected: boolean, template: TransformTemplate | undefined): Result.Result<Declarations, string> => {
|
|
393
|
+
const { attrs, style } = splitPatchForTag(tag, project(drawnValuesOf(values, projected, template)))
|
|
394
|
+
const drawn = Arr.filter(Rec.toEntries(attrs), ([name]) => Bool.not(SVG_FIXED.has(name)))
|
|
395
|
+
const declarations = Arr.appendAll(
|
|
396
|
+
Arr.map(Rec.toEntries(style), ([key, value]) => `${dashCase(key)}:${trimNumbers(String(value))}`),
|
|
397
|
+
Arr.map(drawn, attributeDeclaration),
|
|
398
|
+
)
|
|
399
|
+
return Arr.match(Arr.filter(Rec.keys(attrs), drawnOnlyAsAttribute), {
|
|
400
|
+
onNonEmpty: (names) => Result.fail(`a key SVG draws only as an attribute (${names.join(", ")})`),
|
|
401
|
+
onEmpty: () =>
|
|
402
|
+
Option.match(Arr.findFirst(declarations, (declaration) => UNSAFE.test(declaration)), {
|
|
403
|
+
onSome: (declaration) => Result.fail(`a value CSS text can't hold (${declaration})`),
|
|
404
|
+
onNone: () =>
|
|
405
|
+
Result.succeed({ text: declarations.join(";"), supports: Arr.filter(Arr.map(drawn, ([name]) => name), (name) => SVG_GEOMETRY.has(name)) }),
|
|
406
|
+
}),
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** A Frame's values as the client draws them: a layout node's through its projection at rest (#245), a templated one's through its template (#443). */
|
|
411
|
+
const drawnValuesOf = (values: Values.Values, projected: boolean, template: TransformTemplate | undefined): Readonly<Record<string, unknown>> => {
|
|
412
|
+
const drawn = Bool.match(projected, { onTrue: () => ({ ...values, [Projection.KEY]: Projection.AT_REST }), onFalse: () => values })
|
|
413
|
+
return UndefinedOr.match(template, { onUndefined: () => drawn, onDefined: (t) => ({ ...drawn, [TEMPLATE_KEY]: t }) })
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** A stop's offset in the rule, a percentage to six decimals, so a jump's hold sits before its stop. */
|
|
417
|
+
const percentOf = (stop: number, last: number): string =>
|
|
418
|
+
`${String(Number(Bool.match(last === 0, { onTrue: () => 0, onFalse: () => (stop / last) * 100 }).toFixed(6)) + 0)}%`
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Where a jump falls: halfway between its two Frames, so a display frame drawn near either one, a
|
|
422
|
+
* vsync's jitter early or late, draws that Frame. The step takes a thousandth of a frame.
|
|
423
|
+
*/
|
|
424
|
+
const JUMP_AT = 0.5
|
|
425
|
+
const JUMP_TAKES = 0.001
|
|
426
|
+
|
|
427
|
+
/** A spec sampled into its rule, or why it can't be one. Not cached: {@link keyframes} is. */
|
|
428
|
+
const sample = (spec: Spec): Result.Result<Keyframes, string> =>
|
|
429
|
+
Option.match(framesOf(spec.machine()), {
|
|
430
|
+
onNone: () => Result.fail(`longer than ${MAX_SECONDS} s`),
|
|
431
|
+
onSome: (frames) => {
|
|
432
|
+
const last = frames.length - 1
|
|
433
|
+
const projected = spec.projected === true
|
|
434
|
+
const { kept, end, jumps } = thin(frames, spec.tag)
|
|
435
|
+
/** A kept stop's Frame: the end drawn as the settled Frame. */
|
|
436
|
+
const frameAt = (stop: number): Values.Values => frames[Bool.match(stop === end, { onTrue: () => last, onFalse: () => stop })] as Values.Values
|
|
437
|
+
// The rule's stops: each kept one, and at a jump, the Frame before it held to halfway, then its own.
|
|
438
|
+
const stops = Arr.flatMap(kept, (stop): ReadonlyArray<{ readonly offset: number; readonly frame: Values.Values }> =>
|
|
439
|
+
Bool.match(Arr.contains(jumps, stop), {
|
|
440
|
+
onTrue: () => [
|
|
441
|
+
{ offset: stop - JUMP_AT, frame: frames[stop - 1] as Values.Values },
|
|
442
|
+
{ offset: stop - JUMP_AT + JUMP_TAKES, frame: frameAt(stop) },
|
|
443
|
+
{ offset: stop, frame: frameAt(stop) },
|
|
444
|
+
],
|
|
445
|
+
onFalse: () => [{ offset: stop, frame: frameAt(stop) }],
|
|
446
|
+
}))
|
|
447
|
+
return Result.map(
|
|
448
|
+
Result.all(Arr.map(stops, ({ frame }) => declarationsOf(spec.tag, frame, projected, spec.template))),
|
|
449
|
+
(declarations): Keyframes => {
|
|
450
|
+
const body = Arr.map(Bool.match(end === 0, { onTrue: () => ["0%,100%"], onFalse: () => Arr.map(stops, ({ offset }) => percentOf(offset, end)) }), (offset, i) =>
|
|
451
|
+
`${offset}{${(declarations[i] as Declarations).text}}`).join("")
|
|
452
|
+
const name = ruleNameOf(spec, body)
|
|
453
|
+
return {
|
|
454
|
+
name,
|
|
455
|
+
duration: end * Machine.CATCH_UP_DT,
|
|
456
|
+
settled: last * Machine.CATCH_UP_DT,
|
|
457
|
+
infinite: spec.infinite === true,
|
|
458
|
+
times: Arr.map(kept, (stop) => stop * Machine.CATCH_UP_DT),
|
|
459
|
+
css: `@keyframes ${name}{${body}}`,
|
|
460
|
+
supports: supportsOf(Arr.flatMap(declarations, (d) => d.supports)),
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
)
|
|
464
|
+
},
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* A rule's name: its spec's ({@link nameOf}), or, for a templated spec (#443), its spec's text and
|
|
469
|
+
* its stops hashed, since a function can't name it: two templates that draw the same share one.
|
|
470
|
+
*/
|
|
471
|
+
const ruleNameOf = (spec: Spec, body: string): string =>
|
|
472
|
+
UndefinedOr.match(spec.template, {
|
|
473
|
+
onUndefined: () => nameOf(spec),
|
|
474
|
+
onDefined: () => `${PREFIX}${hash53(`${specTextOf(spec)}|template|${body}`)}`,
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
/** Geometry properties once each, in order: a condition's text is the same for the same set. */
|
|
478
|
+
const supportsOf = (properties: ReadonlyArray<string>): ReadonlyArray<string> => Arr.sort(Arr.dedupe(properties), Order.String)
|
|
479
|
+
|
|
480
|
+
/** An `@supports` condition for geometry properties, `(cx:0px) and (r:0px)`; empty for none. */
|
|
481
|
+
const conditionOf = (properties: ReadonlyArray<string>): string => Arr.map(properties, (property) => `(${property}:0px)`).join(" and ")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The rules sampled so far, across requests, by name, each with whether every CSS colour space was
|
|
486
|
+
* registered when it was (#511); the oldest dropped past `CACHE_SIZE`.
|
|
487
|
+
*/
|
|
488
|
+
// Module state (#460, Q7): a cache keyed by what it caches: each mount animation's sampled keyframes, by its text.
|
|
489
|
+
const cache = new Map<string, Sampled>()
|
|
490
|
+
|
|
491
|
+
/** A spec's rule as sampled, and whether the colour spaces were registered then. */
|
|
492
|
+
interface Sampled {
|
|
493
|
+
readonly spaces: boolean
|
|
494
|
+
readonly rule: Result.Result<Keyframes, string>
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const CACHE_SIZE = 512
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* A spec's rule, or why it has none, for a debug log. Sampled once a spec and cached across requests by
|
|
501
|
+
* its name, since a spec's rule changes only once the page registers every colour space
|
|
502
|
+
* (`Color.registerSpaces`): a wide-gamut colour blends as written before, in sRGB after. Its name stays,
|
|
503
|
+
* so a client matches it whatever it had registered. A templated spec's is sampled each time: its name
|
|
504
|
+
* comes from its stops.
|
|
505
|
+
*/
|
|
506
|
+
// #511; templates #443.
|
|
507
|
+
export const keyframes = (spec: Spec): Result.Result<Keyframes, string> => {
|
|
508
|
+
if (Predicate.isNotUndefined(spec.template)) return sample(spec)
|
|
509
|
+
const name = nameOf(spec)
|
|
510
|
+
const spaces = spacesRegistered()
|
|
511
|
+
const kept = cache.get(name)
|
|
512
|
+
if (Predicate.isNotUndefined(kept) && kept.spaces === spaces) return kept.rule
|
|
513
|
+
const sampled = sample(spec)
|
|
514
|
+
Option.map(Arr.head(Arr.take(Array.from(cache.keys()), Math.max(0, cache.size + 1 - CACHE_SIZE))), (oldest) => cache.delete(oldest))
|
|
515
|
+
cache.set(name, { spaces, rule: sampled })
|
|
516
|
+
return sampled
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* An element's `animation` value for a play of `rule`: its name, length, delay and repeat, filling
|
|
521
|
+
* forwards. Not backwards: through its delay the element shows its inline starting style, which is the
|
|
522
|
+
* rule's first stop for a rule that moves from it.
|
|
523
|
+
*/
|
|
524
|
+
// And the start a reduced rule's snap holds until the delay ends (ADR-0029), where the first stop is
|
|
525
|
+
// already snapped.
|
|
526
|
+
export const animationOf = (rule: Keyframes, delay: number): string =>
|
|
527
|
+
`${rule.name} ${trimNumbers(String(rule.duration))}s linear ${trimNumbers(String(delay))}s ${
|
|
528
|
+
Bool.match(rule.infinite, { onTrue: () => "infinite", onFalse: () => "1" })
|
|
529
|
+
} forwards`
|
|
530
|
+
|
|
531
|
+
// --- A render's sheet --------------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
/** One element's place in a sheet. */
|
|
534
|
+
export interface Slot {
|
|
535
|
+
/** The element's inline `animation`: `var(--wardic-aN)`, which the sheet defines once `define` is called. */
|
|
536
|
+
readonly animation: string
|
|
537
|
+
/**
|
|
538
|
+
* The element's timing: `play`, and under `prefers-reduced-motion: reduce` `reduced` when given
|
|
539
|
+
* (a provider's `"user"`). Undefined until called, and left so when either spec has no rule: the
|
|
540
|
+
* element keeps its Starting style. The reason comes back for the debug log.
|
|
541
|
+
*
|
|
542
|
+
* Defined under `@supports` for the geometry properties its rules write and `supports`: a binding
|
|
543
|
+
* passes the ones every element it orchestrates with writes, so a browser without them keeps the whole
|
|
544
|
+
* group at its starting style, not half of it.
|
|
545
|
+
*/
|
|
546
|
+
// #245.
|
|
547
|
+
readonly define: (play: Play, reduced?: Play | undefined, supports?: ReadonlyArray<string>) => Result.Result<void, string>
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** One render's Appear rules: a slot per element, and the `<style>` text for the head and each late chunk. */
|
|
551
|
+
export interface Sheet {
|
|
552
|
+
/** A slot for the next element. */
|
|
553
|
+
readonly slot: () => Slot
|
|
554
|
+
/**
|
|
555
|
+
* The CSS since the last call: each rule this render hasn't written yet, and the custom property of
|
|
556
|
+
* each slot defined since, the reduced ones under the media query. Called once for the head, and again
|
|
557
|
+
* for each chunk a stream sends after its shell, with only what that chunk adds.
|
|
558
|
+
*/
|
|
559
|
+
// #245.
|
|
560
|
+
readonly css: () => string
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Each `@supports` condition's `:root` declarations, the unconditional ones under `""`. */
|
|
564
|
+
type Blocks = Map<string, Array<string>>
|
|
565
|
+
|
|
566
|
+
/** A block's declarations added under its condition. */
|
|
567
|
+
const addTo = (blocks: Blocks, condition: string, line: string): void => {
|
|
568
|
+
Option.match(Option.fromUndefinedOr(blocks.get(condition)), {
|
|
569
|
+
onNone: () => blocks.set(condition, [line]),
|
|
570
|
+
onSome: (lines) => lines.push(line),
|
|
571
|
+
})
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** The blocks' CSS: the unconditional `:root` first, then each condition's under `@supports`. */
|
|
575
|
+
const blocksCss = (blocks: Blocks): string =>
|
|
576
|
+
Arr.map(Arr.sort(Array.from(blocks.keys()), Order.String), (condition) => {
|
|
577
|
+
const root = `:root{${(blocks.get(condition) as Array<string>).join(";")}}`
|
|
578
|
+
return Bool.match(condition === "", { onTrue: () => root, onFalse: () => `@supports ${condition}{${root}}` })
|
|
579
|
+
}).join("")
|
|
580
|
+
|
|
581
|
+
/** A sheet for one render: a slot per element, and the CSS for the head. */
|
|
582
|
+
export const sheet = (): Sheet => {
|
|
583
|
+
let slots = 0
|
|
584
|
+
// The rules to write, and the names of those written: a chunk doesn't send a rule the head has.
|
|
585
|
+
const rules = new Map<string, string>()
|
|
586
|
+
const written = new Set<string>()
|
|
587
|
+
const blocks: Blocks = new Map()
|
|
588
|
+
const reducedBlocks: Blocks = new Map()
|
|
589
|
+
/** A play's rule added to the sheet: its `animation` value, and the geometry properties it writes. */
|
|
590
|
+
const animationFor = (play: Play): Result.Result<{ readonly animation: string; readonly supports: ReadonlyArray<string> }, string> =>
|
|
591
|
+
Result.map(keyframes(play.spec), (rule) => {
|
|
592
|
+
if (Bool.not(written.has(rule.name))) rules.set(rule.name, rule.css)
|
|
593
|
+
return { animation: animationOf(rule, play.delay), supports: rule.supports }
|
|
594
|
+
})
|
|
595
|
+
return {
|
|
596
|
+
slot: () => {
|
|
597
|
+
const property = `--${PREFIX}a${slots++}`
|
|
598
|
+
return {
|
|
599
|
+
animation: `var(${property})`,
|
|
600
|
+
define: (play, reduced, supports = []) =>
|
|
601
|
+
Result.map(
|
|
602
|
+
Result.all([animationFor(play), UndefinedOr.match(reduced, { onUndefined: () => Result.succeed(undefined), onDefined: animationFor })]),
|
|
603
|
+
([normal, reducedPlay]) => {
|
|
604
|
+
const reducedSupports = UndefinedOr.match(reducedPlay, { onUndefined: () => supports, onDefined: (r) => r.supports })
|
|
605
|
+
const condition = Bool.match(
|
|
606
|
+
Bool.and(Arr.isReadonlyArrayEmpty(normal.supports), Bool.and(Arr.isReadonlyArrayEmpty(reducedSupports), Arr.isReadonlyArrayEmpty(supports))),
|
|
607
|
+
{ onTrue: () => "", onFalse: () => conditionOf(supportsOf(Arr.flatten([normal.supports, reducedSupports, supports]))) },
|
|
608
|
+
)
|
|
609
|
+
addTo(blocks, condition, `${property}:${normal.animation}`)
|
|
610
|
+
UndefinedOr.map(reducedPlay, (r) => addTo(reducedBlocks, condition, `${property}:${r.animation}`))
|
|
611
|
+
},
|
|
612
|
+
),
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
css: () => {
|
|
616
|
+
const text = `${Array.from(rules.values()).join("")}${blocksCss(blocks)}${
|
|
617
|
+
Bool.match(reducedBlocks.size === 0, {
|
|
618
|
+
onTrue: () => "",
|
|
619
|
+
onFalse: () => `@media (prefers-reduced-motion: reduce){${blocksCss(reducedBlocks)}}`,
|
|
620
|
+
})
|
|
621
|
+
}`
|
|
622
|
+
Arr.forEach(Array.from(rules.keys()), (name) => written.add(name))
|
|
623
|
+
rules.clear()
|
|
624
|
+
blocks.clear()
|
|
625
|
+
reducedBlocks.clear()
|
|
626
|
+
return text
|
|
627
|
+
},
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// --- A late chunk's rules ----------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* The attribute a late chunk's `<style>` carries: a chunk a stream sends after its shell brings its own
|
|
635
|
+
* rules, in a node its client doesn't render.
|
|
636
|
+
*/
|
|
637
|
+
// #245, ADR-0044 *Amendment (#245)*.
|
|
638
|
+
export const CHUNK_ATTRIBUTE = "data-wardic-appear-chunk"
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* A late chunk's `<style>` for `css`, as server text; "" for none. With a `nonce`, the one the page's
|
|
642
|
+
* Content Security Policy gives inline styles, it carries it.
|
|
643
|
+
*/
|
|
644
|
+
// `nonce`: #618 (P1), Motion's `MotionConfig` `nonce`.
|
|
645
|
+
export const chunkStyle = (css: string, nonce?: string): string => {
|
|
646
|
+
if (css === "") return ""
|
|
647
|
+
if (Predicate.isUndefined(nonce)) return `<style ${CHUNK_ATTRIBUTE}>${css}</style>`
|
|
648
|
+
return `<style ${CHUNK_ATTRIBUTE} nonce="${escapeAttribute(nonce)}">${css}</style>`
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** `value` safe inside a double-quoted attribute. */
|
|
652
|
+
const escapeAttribute = (value: string): string => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<")
|
|
653
|
+
|
|
654
|
+
/** The documents {@link adoptChunks} watches, or has: one observer a document. */
|
|
655
|
+
// Module state (#460, Q7): the documents already watched, held weakly.
|
|
656
|
+
const watched = new WeakSet<Document>()
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Moves each late chunk's Appear rules into the head: its `<style data-wardic-appear-chunk>`'s text into a
|
|
660
|
+
* head `<style>`, then the node out, before the chunk hydrates, since a hydrating client expects no node it
|
|
661
|
+
* doesn't render. The text goes first, so the rule a running animation plays is never missing. Chunks
|
|
662
|
+
* already in the document move at once; while it's still loading, a stream still coming, a
|
|
663
|
+
* `MutationObserver` moves each as it lands, until `DOMContentLoaded`. Every call moves what's there; one
|
|
664
|
+
* observer a document.
|
|
665
|
+
*/
|
|
666
|
+
// #245. Leaving the node would shift sibling selectors. Each provider hydrating asks before its subtree
|
|
667
|
+
// hydrates.
|
|
668
|
+
export const adoptChunks = (doc: Document): void => {
|
|
669
|
+
const adopt = (): void =>
|
|
670
|
+
Arr.forEach(Array.from(doc.querySelectorAll(`style[${CHUNK_ATTRIBUTE}]`)), (node) => {
|
|
671
|
+
const style = doc.createElement("style")
|
|
672
|
+
style.setAttribute("data-wardic-appear", "")
|
|
673
|
+
style.textContent = node.textContent
|
|
674
|
+
doc.head.append(style)
|
|
675
|
+
node.remove()
|
|
676
|
+
})
|
|
677
|
+
adopt()
|
|
678
|
+
if (Bool.or(doc.readyState !== "loading", watched.has(doc))) return
|
|
679
|
+
watched.add(doc)
|
|
680
|
+
const observer = new MutationObserver(adopt)
|
|
681
|
+
observer.observe(doc.documentElement, { childList: true, subtree: true })
|
|
682
|
+
doc.addEventListener("DOMContentLoaded", () => {
|
|
683
|
+
adopt()
|
|
684
|
+
observer.disconnect()
|
|
685
|
+
}, { once: true })
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// --- The Handoff -------------------------------------------------------------------------------
|
|
689
|
+
|
|
690
|
+
/** A `CSSAnimation`'s name, where the animation has one. */
|
|
691
|
+
const nameOfAnimation = (animation: Animation): Option.Option<string> =>
|
|
692
|
+
Option.filter(Option.some((animation as Animation & { readonly animationName?: unknown }).animationName), Predicate.isString)
|
|
693
|
+
|
|
694
|
+
/** The element's Appear animation: its `wardic-…` CSS animation, running, or finished and filling. */
|
|
695
|
+
// ADR-0044, decision 6.
|
|
696
|
+
export const find = (element: Element): Option.Option<Animation> =>
|
|
697
|
+
Bool.match(Predicate.hasProperty(element, "getAnimations"), {
|
|
698
|
+
onFalse: () => Option.none(),
|
|
699
|
+
onTrue: () => Arr.findFirst(element.getAnimations(), (animation) => Option.exists(nameOfAnimation(animation), (name) => name.startsWith(PREFIX))),
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
/** Whether `animation` is `spec`'s: the rule the server sampled from the same spec. */
|
|
703
|
+
export const matches = (animation: Animation, spec: Pick<Spec, "key" | "tag" | "infinite" | "projected">): boolean =>
|
|
704
|
+
Option.exists(nameOfAnimation(animation), (name) => name === nameOf(spec))
|
|
705
|
+
|
|
706
|
+
/** How far `animation` has got, in seconds since it started (its delay included): 0 before it has. */
|
|
707
|
+
export const elapsedOf = (animation: Animation): number =>
|
|
708
|
+
Option.match(Option.filter(Option.fromNullishOr(animation.currentTime), Predicate.isNumber), {
|
|
709
|
+
onNone: () => 0,
|
|
710
|
+
onSome: (ms) => Math.max(0, ms / 1000),
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
/** The delay `animation` started after, in seconds: what its element's `animation-delay` holds. */
|
|
714
|
+
export const delayOf = (animation: Animation): number =>
|
|
715
|
+
Option.match(Option.filter(Option.fromNullishOr(animation.effect), (effect) => Predicate.isNotUndefined(effect.getTiming)), {
|
|
716
|
+
onNone: () => 0,
|
|
717
|
+
onSome: (effect) => Option.getOrElse(Option.filter(Option.some(effect.getTiming().delay), Predicate.isNumber), () => 0) / 1000,
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
/** What a slot's inline `animation` starts with: `var(--wardic-a`. */
|
|
721
|
+
const SLOT_PREFIX = `var(--${PREFIX}a`
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Whether `element`'s Appear animation is running, not yet handed over: its inline style still names its
|
|
725
|
+
* slot, which `release` removes. A layout measure asks it of each node, so a running animation's
|
|
726
|
+
* transform, which beats the inline one, stays out of the box it reads.
|
|
727
|
+
*/
|
|
728
|
+
// #245: a Measure pass; a string read, no style resolved.
|
|
729
|
+
export const pending = (element: ElementCSSInlineStyle): boolean => element.style.getPropertyValue("animation").startsWith(SLOT_PREFIX)
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* The handover's last step: the element's inline `animation` removed, in the task that writes the
|
|
733
|
+
* engine's first frame, so no paint falls between. While it ran, its value beat the inline style the
|
|
734
|
+
* engine writes; removing it ends the CSS animation at that task's style update.
|
|
735
|
+
*/
|
|
736
|
+
// The **Handoff** (ADR-0044, decision 6). A `cancel()` too changed nothing (a mutation, #48's PR 1).
|
|
737
|
+
export const release = (element: ElementCSSInlineStyle): void => {
|
|
738
|
+
element.style.removeProperty("animation")
|
|
739
|
+
}
|