@brandocms/jupiter 5.0.0-beta.18 → 5.0.0-beta.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brandocms/jupiter",
3
- "version": "5.0.0-beta.18",
3
+ "version": "5.0.0-beta.19",
4
4
  "description": "Frontend helpers.",
5
5
  "author": "Univers/Twined",
6
6
  "license": "UNLICENSED",
@@ -57,6 +57,12 @@ const DEFAULT_OPTIONS = {
57
57
  snapDuration: 0.5, // Duration of snap animation in seconds (0.3-1.0, lower = faster/snappier)
58
58
  snapBounce: 0.15, // Spring bounce amount (0-1, 0 = no bounce, higher = more bouncy)
59
59
 
60
+ // Edge spring (loop: false) — a throw past either end keeps its speed, runs
61
+ // past the edge and springs back, instead of gliding in slowly
62
+ edgeStiffness: 260, // Pull back towards the edge (higher = shorter overshoot)
63
+ edgeDamping: 29, // Damping of the pull (29 at 260 is just under critical: one overshoot, no wobble)
64
+ edgeElasticity: 0.55, // Dragging past an end follows the finger with this much give (0 = hard stop at the edge)
65
+
60
66
  speed: {
61
67
  sm: 0.1, // Speed for mobile (multiplier)
62
68
  lg: 0.35, // Speed for desktop (multiplier)
@@ -910,7 +916,9 @@ function horizontalLoop(app, items, config) {
910
916
  indexSetByNav = false
911
917
  startX = e.clientX
912
918
  startY = e.clientY
913
- startPosition = position.get()
919
+ // Grabbed past an end (mid-bounce): continue from the drag distance that
920
+ // shows the row there, or the rubber band would jump
921
+ startPosition = shouldLoop ? position.get() : unstretch(position.get())
914
922
  velocityTracker = [{ x: e.clientX, time: e.timeStamp }]
915
923
  hasDragged = false // Reset - will be set true if movement exceeds threshold
916
924
  axisDecided = false // Reset - will be decided on first significant movement
@@ -1030,9 +1038,8 @@ function horizontalLoop(app, items, config) {
1030
1038
  // For looping, position grows freely - items wrap as groups
1031
1039
  position.set(newPosition)
1032
1040
  } else {
1033
- // For non-looping, clamp position to maxScrollPosition (last item at right edge)
1034
- const clampedPos = Math.max(0, Math.min(maxScrollPosition, newPosition))
1035
- position.set(clampedPos)
1041
+ // For non-looping, past either end the row gives with resistance
1042
+ position.set(stretch(newPosition))
1036
1043
  }
1037
1044
  }
1038
1045
 
@@ -1093,6 +1100,18 @@ function horizontalLoop(app, items, config) {
1093
1100
  if (overallDelta > 0 && velocity < 0) velocity = 0
1094
1101
  if (overallDelta < 0 && velocity > 0) velocity = 0
1095
1102
 
1103
+ // Released past an end: always spring back to it, whatever the velocity
1104
+ if (!shouldLoop && outOfBounds(position.get())) {
1105
+ inertiaAnimation = throwPastEdge(-velocity * config.throwVelocityMultiplier)
1106
+ inertiaAnimation
1107
+ .then(() => {
1108
+ inertiaAnimation = null
1109
+ updateIndexDisplay()
1110
+ })
1111
+ .catch(() => { inertiaAnimation = null })
1112
+ return
1113
+ }
1114
+
1096
1115
  // If snap is enabled, always use it (GSAP-style: snap modifies inertia target)
1097
1116
  // Otherwise use old logic: inertia if velocity, or resume crawl
1098
1117
  if (config.snap) {
@@ -1124,6 +1143,15 @@ function horizontalLoop(app, items, config) {
1124
1143
  const velocityMultiplier = reducedMotion ? config.throwVelocityMultiplier * 0.3 : config.throwVelocityMultiplier
1125
1144
  const motionVelocity = -velocity * velocityMultiplier
1126
1145
 
1146
+ // A throw that would land past either end springs off the edge instead
1147
+ if (!reducedMotion && throwsPastEdge(currentPos, motionVelocity)) {
1148
+ inertiaAnimation = throwPastEdge(motionVelocity)
1149
+ inertiaAnimation
1150
+ .then(() => { inertiaAnimation = null })
1151
+ .catch(() => { inertiaAnimation = null })
1152
+ return
1153
+ }
1154
+
1127
1155
  // Estimate target for Motion.js (it recomputes internally for type: 'inertia',
1128
1156
  // but we pass a reasonable target to avoid a zero-distance animation)
1129
1157
  const power = config.throwPower
@@ -1162,6 +1190,142 @@ function horizontalLoop(app, items, config) {
1162
1190
  })
1163
1191
  }
1164
1192
 
1193
+ function outOfBounds(pos) {
1194
+ return pos > maxScrollPosition || pos < 0
1195
+ }
1196
+
1197
+ /**
1198
+ * Rubber band for dragging past an end of a non-looping row — the curve
1199
+ * iOS uses, (1 - 1 / (x·c/d + 1)) · d: the row follows the pointer at
1200
+ * `edgeElasticity` of its speed at the edge, slower the further out, and
1201
+ * never further than its own width. `stretch` maps drag distance to the
1202
+ * shown position, `unstretch` inverts it, `stretchRate` is the slope.
1203
+ */
1204
+ function stretch(raw) {
1205
+ const c = config.edgeElasticity
1206
+ if (!c) return Math.max(0, Math.min(maxScrollPosition, raw))
1207
+ const d = containerWidth || container.offsetWidth
1208
+ const band = over => (1 - 1 / ((over * c) / d + 1)) * d
1209
+ if (raw > maxScrollPosition) return maxScrollPosition + band(raw - maxScrollPosition)
1210
+ if (raw < 0) return -band(-raw)
1211
+ return raw
1212
+ }
1213
+
1214
+ function unstretch(shown) {
1215
+ const c = config.edgeElasticity
1216
+ if (!c || !outOfBounds(shown)) return shown
1217
+ const d = containerWidth || container.offsetWidth
1218
+ const over = shown > maxScrollPosition ? shown - maxScrollPosition : -shown
1219
+ const raw = (d / c) * (1 / (1 - Math.min(over, d - 1) / d) - 1)
1220
+ return shown > maxScrollPosition ? maxScrollPosition + raw : -raw
1221
+ }
1222
+
1223
+ function stretchRate(shown) {
1224
+ const c = config.edgeElasticity
1225
+ if (!c || !outOfBounds(shown)) return 1
1226
+ const d = containerWidth || container.offsetWidth
1227
+ const over = shown > maxScrollPosition ? shown - maxScrollPosition : -shown
1228
+ // dy/dx = c / (x·c/d + 1)², written in terms of the shown overshoot
1229
+ return c * (1 - over / d) ** 2
1230
+ }
1231
+
1232
+ /**
1233
+ * Whether a throw would come to rest past either end of a non-looping row.
1234
+ * Uses Motion's own inertia arithmetic: it rests at origin + power * velocity.
1235
+ */
1236
+ function throwsPastEdge(currentPos, motionVelocity) {
1237
+ if (shouldLoop) return false
1238
+ if (outOfBounds(currentPos)) return true
1239
+ const ideal = currentPos + config.throwPower * motionVelocity
1240
+ return ideal > maxScrollPosition || ideal < 0
1241
+ }
1242
+
1243
+ /**
1244
+ * Throw into an end of a non-looping row: keep the speed, reach the edge,
1245
+ * run past it and spring back.
1246
+ *
1247
+ * The plain inertia animations clamp their target to the row (snap via
1248
+ * `modifyTarget`), and Motion then rescales the whole throw to that shorter
1249
+ * distance — so a hard throw near the end crawled the last stretch on the
1250
+ * exponential tail, two seconds of it. Motion's own `min`/`max` bounce
1251
+ * cannot help: the clamped target never reaches the boundary, and its
1252
+ * spring is handed a velocity in px/ms rather than px/s (see the TODO in
1253
+ * motion-dom's inertia generator), so it barely overshoots when it does.
1254
+ *
1255
+ * Motion's inertia is closed-form — x(t) = target - A·e^(-t/τ), with
1256
+ * A = power · velocity and target = origin + A — so the moment it crosses
1257
+ * the edge, and its speed there, are known up front. Phase one is a tween
1258
+ * to the edge whose easing *is* that curve; phase two a spring to the edge
1259
+ * starting at that speed, which carries it past and back.
1260
+ *
1261
+ * Not a listener on the value: starting the spring from inside the decay's
1262
+ * own `change` notification left the value NaN (the decay was mid-frame),
1263
+ * and the row froze with every later drag computing from NaN.
1264
+ *
1265
+ * Returns Motion-like controls — `stop()` and a thenable — so
1266
+ * `onPointerDown` interrupts it like any other animation.
1267
+ */
1268
+ function throwPastEdge(motionVelocity) {
1269
+ const origin = position.get()
1270
+ // Already past an end (released mid-stretch): back to that end, starting
1271
+ // at the speed the row is actually moving on screen — the pointer's,
1272
+ // slowed by the rubber band.
1273
+ const outside = outOfBounds(origin)
1274
+ const edge = outside
1275
+ ? (origin > maxScrollPosition ? maxScrollPosition : 0)
1276
+ : (motionVelocity > 0 ? maxScrollPosition : 0)
1277
+ findNearestSnapPoint(edge) // lands on the last (or first) item: keep the index in step
1278
+
1279
+ const tau = config.throwResistance // ms
1280
+ const amplitude = config.throwPower * motionVelocity
1281
+ const beyond = origin + amplitude - edge // how far past the edge the decay would rest
1282
+ // Time (ms) the decay takes to reach the edge, and its speed there (px/s)
1283
+ const tEdge = outside ? 0 : Math.max(0, -tau * Math.log(beyond / amplitude))
1284
+ const edgeVelocity = outside
1285
+ ? motionVelocity * stretchRate(origin)
1286
+ : (beyond / tau) * 1000
1287
+
1288
+ let current = null
1289
+ let stopped = false
1290
+ let resolve
1291
+ const done = new Promise(r => { resolve = r })
1292
+
1293
+ const bounce = () => {
1294
+ if (stopped) return
1295
+ current = animate(position, edge, {
1296
+ type: 'spring',
1297
+ velocity: edgeVelocity,
1298
+ stiffness: config.edgeStiffness,
1299
+ damping: config.edgeDamping,
1300
+ restSpeed: 10,
1301
+ restDelta: 0.5,
1302
+ })
1303
+ current.then(resolve, resolve)
1304
+ }
1305
+
1306
+ if (tEdge < 1 || edge === origin) {
1307
+ bounce()
1308
+ } else {
1309
+ const span = edge - origin
1310
+ current = animate(position, edge, {
1311
+ duration: tEdge / 1000,
1312
+ // The decay's own curve, normalised to 0..1 over the way to the edge
1313
+ ease: p => (amplitude * (1 - Math.exp((-p * tEdge) / tau))) / span,
1314
+ })
1315
+ current.then(bounce, () => {})
1316
+ }
1317
+
1318
+ return {
1319
+ stop() {
1320
+ stopped = true
1321
+ current?.stop()
1322
+ resolve()
1323
+ },
1324
+ then: (onResolve, onReject) => done.then(onResolve, onReject),
1325
+ catch: onReject => done.catch(onReject),
1326
+ }
1327
+ }
1328
+
1165
1329
  /**
1166
1330
  * Find the nearest snap point to a given position
1167
1331
  * @param {number} targetPos - Position to find nearest snap point for
@@ -1248,6 +1412,18 @@ function horizontalLoop(app, items, config) {
1248
1412
  const motionVelocity =
1249
1413
  -velocity * config.throwVelocityMultiplier * config.snapVelocityMultiplier
1250
1414
 
1415
+ // A throw that would land past either end springs off the edge instead
1416
+ if (throwsPastEdge(currentPos, motionVelocity)) {
1417
+ snapAnimation = throwPastEdge(motionVelocity)
1418
+ snapAnimation
1419
+ .then(() => {
1420
+ snapAnimation = null
1421
+ updateIndexDisplay()
1422
+ })
1423
+ .catch(() => { snapAnimation = null })
1424
+ return
1425
+ }
1426
+
1251
1427
  // Calculate ideal inertia target (Motion will recalculate, but we need a non-zero animation)
1252
1428
  // This ensures Motion starts the inertia physics
1253
1429
  const idealTarget =
@@ -1909,6 +2085,9 @@ export default class Looper {
1909
2085
  snapVelocityMultiplier: this.opts.snapVelocityMultiplier,
1910
2086
  snapDuration: this.opts.snapDuration,
1911
2087
  snapBounce: this.opts.snapBounce,
2088
+ edgeStiffness: this.opts.edgeStiffness,
2089
+ edgeDamping: this.opts.edgeDamping,
2090
+ edgeElasticity: this.opts.edgeElasticity,
1912
2091
  minimumMovement: this.opts.minimumMovement,
1913
2092
  touchMinimumMovement: this.opts.touchMinimumMovement,
1914
2093
  },