chenguini 0.0.0-stage → 0.0.1
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 +4 -2
- package/demos/draggable-cards/index.html +26 -0
- package/demos/draggable-cards/index.ts +282 -0
- package/demos/draggable-cards/swap-layout.ts +40 -0
- package/demos/gesture-flick-snap-target/index.html +63 -0
- package/demos/gesture-flick-snap-target/index.ts +262 -0
- package/demos/gesture-flick-snap-target/snap-layout.ts +44 -0
- package/demos/global-illumination-fast-approx/index.html +23 -0
- package/demos/global-illumination-fast-approx/index.ts +413 -0
- package/demos/global-illumination-pathtracer/index.html +56 -0
- package/demos/global-illumination-pathtracer/index.ts +638 -0
- package/demos/life-calendar/grid-sizing.ts +34 -0
- package/demos/life-calendar/index.html +36 -0
- package/demos/life-calendar/index.ts +235 -0
- package/demos/mobile-test-standalone/index.html +140 -0
- package/demos/mobile-test-standalone/index.ts +686 -0
- package/demos/nicer-hacker-news/data.ts +1790 -0
- package/demos/nicer-hacker-news/debug.js +39 -0
- package/demos/nicer-hacker-news/hn-comment-layout.ts +668 -0
- package/demos/nicer-hacker-news/hn-comment-parser.ts +228 -0
- package/demos/nicer-hacker-news/hn-thread-layout.ts +193 -0
- package/demos/nicer-hacker-news/index.html +101 -0
- package/demos/nicer-hacker-news/index.ts +845 -0
- package/demos/nicer-hacker-news/pages.html +160 -0
- package/demos/photo-gallery/data.ts +230 -0
- package/demos/photo-gallery/index.html +86 -0
- package/demos/photo-gallery/index.ts +705 -0
- package/demos/photo-gallery/layout.ts +367 -0
- package/demos/photo-gallery/prompt-layout.ts +127 -0
- package/demos/predictive-keyframes/index.html +13 -0
- package/demos/predictive-keyframes/index.ts +388 -0
- package/demos/scroll-anchor/anchor-layout.ts +136 -0
- package/demos/scroll-anchor/index.html +165 -0
- package/demos/scroll-anchor/index.ts +204 -0
- package/demos/scroll-anchor/scroll-anchor-react-bug.html +399 -0
- package/demos/spring-comparison/index.html +12 -0
- package/demos/spring-comparison/index.ts +239 -0
- package/docs/AddToHomeScreen.md +66 -0
- package/docs/Animation.md +142 -0
- package/docs/FrameScheduling.md +46 -0
- package/docs/Input.md +116 -0
- package/docs/Scrolling.md +138 -0
- package/docs/data-migration.md +116 -0
- package/docs/download-quirks-in-browser.md +6 -0
- package/docs/engineering.md +266 -0
- package/docs/file-system-api-quirks.md +3 -0
- package/docs/media.md +84 -0
- package/docs/router.md +376 -0
- package/docs/server-rendering-and-hot-reloading.md +113 -0
- package/docs/ui.md +334 -0
- package/docs/z-index-depth-management.md +99 -0
- package/package.json +25 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cheng Lou
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Chenguini
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**CH**eng's **ENG**ineering & **UI** guidelines for avoiding spaghetti code
|
|
4
|
+
|
|
5
|
+
See docs/ and demos/. Ignore docs/drafts for now
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<head>
|
|
3
|
+
<meta charset="utf-8" />
|
|
4
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
5
|
+
<title>Draggable Cards</title>
|
|
6
|
+
<style>
|
|
7
|
+
body {
|
|
8
|
+
font: 16px system-ui;
|
|
9
|
+
user-select: none; /* no text selection needed */
|
|
10
|
+
-webkit-user-select: none; /* no text selection needed */
|
|
11
|
+
background-color: #eee;
|
|
12
|
+
overflow: hidden; /* prevent accidentally dragging the viewport on iOS */
|
|
13
|
+
margin: 0;
|
|
14
|
+
}
|
|
15
|
+
.row {
|
|
16
|
+
position: absolute;
|
|
17
|
+
display: grid;
|
|
18
|
+
place-items: center;
|
|
19
|
+
overflow: hidden; /* height might be small during animation */
|
|
20
|
+
transition: box-shadow 0.25s ease-out, opacity 0.25s ease-out;
|
|
21
|
+
}
|
|
22
|
+
</style>
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
<script src="./index.ts" type="module"></script>
|
|
26
|
+
</body>
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import {center, rowSizeX, shouldSwapDown, shouldSwapUp, stackLayout, windowPaddingTop} from './swap-layout'
|
|
2
|
+
|
|
3
|
+
// === generic scheduler & its debugger
|
|
4
|
+
let scheduledRaf: number | null = null
|
|
5
|
+
function scheduleRender() {
|
|
6
|
+
if (scheduledRaf != null) return;
|
|
7
|
+
scheduledRaf = requestAnimationFrame(function renderAndMaybeScheduleAnotherRender(now) { // eye-grabbing name. No "(anonymous)" function in the debugger & profiler
|
|
8
|
+
scheduledRaf = null
|
|
9
|
+
if (render(now)) scheduleRender()
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// === generic spring physics
|
|
14
|
+
// 4ms/step for the spring animation's step. Typically 4 steps for 60fps (16.6ms/frame) and 2 for 120fps (8.3ms/frame). Frame time delta varies, so not always true
|
|
15
|
+
// could use 8ms instead, but 120fps' 8.3ms/frame means the computation might not fit in the remaining 0.3ms, which means sometime the simulation step wouldn't even run once, giving the illusion of jank
|
|
16
|
+
const msPerAnimationStep = 4
|
|
17
|
+
|
|
18
|
+
type SpringConfig = {
|
|
19
|
+
pos: number
|
|
20
|
+
dest: number
|
|
21
|
+
v: number
|
|
22
|
+
k: number
|
|
23
|
+
b: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function spring(pos: number, v = 0, k = 290, b = 24): SpringConfig {
|
|
27
|
+
return {pos, dest: pos, v, k, b} // k = stiffness, b = damping. Try https://chenglou.me/react-motion/demos/demo5-spring-parameters-chooser/
|
|
28
|
+
}
|
|
29
|
+
function springStep(config: SpringConfig) {
|
|
30
|
+
// https://blog.maximeheckel.com/posts/the-physics-behind-spring-animations/
|
|
31
|
+
// this seems inspired by https://github.com/chenglou/react-motion/blob/9e3ce95bacaa9a1b259f969870a21c727232cc68/src/stepper.js
|
|
32
|
+
const t = msPerAnimationStep / 1000 // convert to seconds for the physics equation
|
|
33
|
+
const {pos, dest, v, k, b} = config
|
|
34
|
+
// for animations, dest is actually spring at rest. Current position is the spring's stretched/compressed state
|
|
35
|
+
const Fspring = -k * (pos - dest) // Spring stiffness, in kg / s^2
|
|
36
|
+
const Fdamper = -b * v // Damping, in kg / s
|
|
37
|
+
const a = Fspring + Fdamper // a needs to be divided by mass, but we'll assume mass of 1. Adjust k and b to change spring curve instead
|
|
38
|
+
const newV = v + a * t
|
|
39
|
+
const newPos = pos + newV * t
|
|
40
|
+
|
|
41
|
+
config.pos = newPos; config.v = newV
|
|
42
|
+
}
|
|
43
|
+
function springGoToEnd(config: SpringConfig) {
|
|
44
|
+
config.pos = config.dest
|
|
45
|
+
config.v = 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// === generic helpers
|
|
49
|
+
function hash(n: number) {
|
|
50
|
+
n = Math.imul((n >>> 16) ^ n, 0x21f0aaad);
|
|
51
|
+
n = Math.imul((n >>> 15) ^ n, 0xd35a2d97);
|
|
52
|
+
return (((n >>> 15) ^ n) >>> 0) / 0x100000000;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// === state
|
|
56
|
+
type RowData = {
|
|
57
|
+
id: string
|
|
58
|
+
sizeY: number
|
|
59
|
+
x: SpringConfig
|
|
60
|
+
y: SpringConfig
|
|
61
|
+
scale: SpringConfig
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type DragInfo = {
|
|
65
|
+
id: string
|
|
66
|
+
deltaX: number
|
|
67
|
+
deltaY: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type PointerSample = {x: number; y: number; time: number}
|
|
71
|
+
|
|
72
|
+
type State = {
|
|
73
|
+
animatedUntilTime: number | null
|
|
74
|
+
dragged: DragInfo | null
|
|
75
|
+
lastDragged: DragInfo | null
|
|
76
|
+
pointerState: 'down' | 'up' | 'firstDown'
|
|
77
|
+
pointer: PointerSample[]
|
|
78
|
+
events: {
|
|
79
|
+
mouseup: MouseEvent | null
|
|
80
|
+
touchend: TouchEvent | null
|
|
81
|
+
touchcancel: TouchEvent | null
|
|
82
|
+
mousemove: MouseEvent | null
|
|
83
|
+
touchmove: TouchEvent | null
|
|
84
|
+
pointerdown: PointerEvent | null
|
|
85
|
+
}
|
|
86
|
+
data: RowData[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// cache lifetime: same as data
|
|
90
|
+
const domCache: HTMLDivElement[] = []
|
|
91
|
+
for (let i = 0; i < 5; i++) {
|
|
92
|
+
const node = document.createElement("div")
|
|
93
|
+
const sizeY = 30 + hash(i * 2) * 150 // [30, 180)
|
|
94
|
+
const rand = hash(i * 2 + 1) * 40 + 40 // Range: [40, 80]
|
|
95
|
+
// static properties: set once at creation
|
|
96
|
+
node.className = "row"
|
|
97
|
+
node.textContent = "Drag Me " + i
|
|
98
|
+
node.style.width = `${rowSizeX}px`
|
|
99
|
+
node.style.height = `${sizeY}px`
|
|
100
|
+
node.style.outline = `1px solid hsl(205, 100%, ${rand}%)` // blue hue
|
|
101
|
+
node.style.backgroundColor = `hsl(205, 100%, ${rand + 10}%)` // lighter blue hue
|
|
102
|
+
domCache.push(node)
|
|
103
|
+
document.body.appendChild(node)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
const st: State = {
|
|
108
|
+
animatedUntilTime: null,
|
|
109
|
+
dragged: null,
|
|
110
|
+
lastDragged: null,
|
|
111
|
+
pointerState: 'up',
|
|
112
|
+
pointer: [{x: 0, y: 0, time: 0}], // circular buffer. Btw, on page load, there's no way to render a first cursor state =(
|
|
113
|
+
events: { mouseup: null, touchend: null, touchcancel: null, mousemove: null, touchmove: null, pointerdown: null },
|
|
114
|
+
data: [],
|
|
115
|
+
}
|
|
116
|
+
{
|
|
117
|
+
const windowSizeX = document.documentElement.clientWidth // excludes scroll bar & invariant under safari pinch zoom
|
|
118
|
+
for (let i = 0; i < 5; i++) {
|
|
119
|
+
const sizeY = 30 + hash(i * 2) * 150 // [30, 180)
|
|
120
|
+
st.data.push({
|
|
121
|
+
id: i + '', // gonna drag rows around so we can't refer to a row by index. Assign a stable id
|
|
122
|
+
sizeY,
|
|
123
|
+
x: spring(center(rowSizeX, windowSizeX)),
|
|
124
|
+
y: spring(0),
|
|
125
|
+
scale: spring(1),
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function springForEach(f: (s: SpringConfig) => void) {
|
|
130
|
+
for (const d of st.data) {
|
|
131
|
+
f(d.x); f(d.y); f(d.scale) // no different than [a, b, c].forEach(f)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// === events
|
|
136
|
+
// See docs/Input.md.
|
|
137
|
+
window.addEventListener('resize', () => scheduleRender())
|
|
138
|
+
window.addEventListener("mouseup", (e) => {st.events.mouseup = e; scheduleRender()})
|
|
139
|
+
window.addEventListener("touchend", (e) => {st.events.touchend = e; scheduleRender()})
|
|
140
|
+
window.addEventListener("touchcancel", (e) => {st.events.touchcancel = e; scheduleRender()})
|
|
141
|
+
window.addEventListener("mousemove", (e) => {st.events.mousemove = e; scheduleRender()})
|
|
142
|
+
window.addEventListener("touchmove", (e) => {st.events.touchmove = e; scheduleRender()})
|
|
143
|
+
window.addEventListener('pointerdown', (e) => {st.events.pointerdown = e; scheduleRender()})
|
|
144
|
+
|
|
145
|
+
// === hit testing logic. Boxes' hit area should be static and not follow their current animated state usually (but we can do either). Use the dynamic area here for once
|
|
146
|
+
function hitTest(data: RowData[], pointer: PointerSample) {
|
|
147
|
+
for (const d of data) {
|
|
148
|
+
const {x, y, sizeY} = d
|
|
149
|
+
if (x.pos <= pointer.x && pointer.x < x.pos + rowSizeX && y.pos <= pointer.y && pointer.y < y.pos + sizeY) return d // pointer on this box
|
|
150
|
+
}
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function render(now: number) {
|
|
155
|
+
// === step 0: process events
|
|
156
|
+
// mouseup/touchend
|
|
157
|
+
if (st.events.mouseup != null || st.events.touchend != null || st.events.touchcancel != null) st.pointerState = 'up'
|
|
158
|
+
if (st.events.mouseup != null) st.pointer.push({x: st.events.mouseup.pageX, y: st.events.mouseup.pageY, time: st.events.mouseup.timeStamp})
|
|
159
|
+
if (st.events.touchcancel != null) {
|
|
160
|
+
const touch = st.events.touchcancel.changedTouches[0]
|
|
161
|
+
if (touch != null) st.pointer.push({x: touch.pageX, y: touch.pageY, time: st.events.touchcancel.timeStamp})
|
|
162
|
+
}
|
|
163
|
+
if (st.events.touchend != null) {
|
|
164
|
+
const touch = st.events.touchend.changedTouches[0]
|
|
165
|
+
if (touch != null) st.pointer.push({x: touch.pageX, y: touch.pageY, time: st.events.touchend.timeStamp})
|
|
166
|
+
}
|
|
167
|
+
// move
|
|
168
|
+
// when scrolling (which might schedule a render), a container's pointermove doesn't trigger, so the pointer's local coordinates are stale
|
|
169
|
+
// this means we should only use pointer's global coordinates, which is always right
|
|
170
|
+
if (st.events.mousemove != null) st.pointer.push({x: st.events.mousemove.pageX, y: st.events.mousemove.pageY, time: st.events.mousemove.timeStamp})
|
|
171
|
+
if (st.events.touchmove != null) st.pointer.push({x: st.events.touchmove.touches[0]!.pageX, y: st.events.touchmove.touches[0]!.pageY, time: st.events.touchmove.timeStamp})
|
|
172
|
+
// down
|
|
173
|
+
if (st.events.pointerdown != null) {
|
|
174
|
+
st.pointerState = 'firstDown'
|
|
175
|
+
st.pointer.push({x: st.events.pointerdown.pageX, y: st.events.pointerdown.pageY, time: st.events.pointerdown.timeStamp})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// === step 1: batched DOM reads (to avoid accidental DOM read & write interleaving)
|
|
179
|
+
const windowSizeX = document.documentElement.clientWidth // excludes scroll bar & invariant under safari pinch zoom
|
|
180
|
+
const pointerLast = st.pointer.at(-1)! // guaranteed non-null since pointer.length >= 1
|
|
181
|
+
|
|
182
|
+
// === step 2: handle inputs-related state change
|
|
183
|
+
let newDragged: DragInfo | null
|
|
184
|
+
if (st.pointerState === 'down') newDragged = st.dragged
|
|
185
|
+
else if (st.pointerState === 'up') {
|
|
186
|
+
if (st.dragged != null) {
|
|
187
|
+
const dragIdx = st.data.findIndex(d => d.id === st.dragged!.id)
|
|
188
|
+
let i = st.pointer.length - 1
|
|
189
|
+
while (i > 0 && pointerLast.time - st.pointer[i - 1]!.time <= 100) i--
|
|
190
|
+
const deltaTime = pointerLast.time - st.pointer[i]!.time
|
|
191
|
+
if (deltaTime > 0) {
|
|
192
|
+
const vx = (pointerLast.x - st.pointer[i]!.x) / deltaTime * 1000 // speed over ~1s
|
|
193
|
+
const vy = (pointerLast.y - st.pointer[i]!.y) / deltaTime * 1000
|
|
194
|
+
st.data[dragIdx]!.x.v += vx
|
|
195
|
+
st.data[dragIdx]!.y.v += vy
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
newDragged = null
|
|
199
|
+
} else {
|
|
200
|
+
const hit = hitTest(st.data, pointerLast)
|
|
201
|
+
if (hit != null) newDragged = {id: hit.id, deltaX: pointerLast.x - hit.x.pos, deltaY: pointerLast.y - hit.y.pos}
|
|
202
|
+
else newDragged = null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// === step 3: calculate new layout & cursor
|
|
206
|
+
if (newDragged != null) { // first, swap row based on cursor position if needed
|
|
207
|
+
let dragIdx = st.data.findIndex(d => d.id === newDragged.id) // guaranteed non-null
|
|
208
|
+
const d = st.data[dragIdx]!
|
|
209
|
+
const x = pointerLast.x - newDragged.deltaX
|
|
210
|
+
const y = pointerLast.y - newDragged.deltaY
|
|
211
|
+
d.x.pos = d.x.dest = x + (center(rowSizeX, windowSizeX) - x) / 1.5 // restrict horizontal drag a bit
|
|
212
|
+
d.y.pos = d.y.dest = y
|
|
213
|
+
d.scale.dest = 1.1
|
|
214
|
+
// dragging row upward? Swap it with previous row if cursor is above the first half previous row
|
|
215
|
+
while (dragIdx > 0 && shouldSwapUp(pointerLast.y, st.data[dragIdx - 1]!.y.dest, st.data[dragIdx - 1]!.sizeY)) {
|
|
216
|
+
[st.data[dragIdx], st.data[dragIdx - 1]] = [st.data[dragIdx - 1]!, st.data[dragIdx]!] // swap
|
|
217
|
+
dragIdx--
|
|
218
|
+
}
|
|
219
|
+
// dragging row downward? Swap it with next row if cursor is below the first half next row
|
|
220
|
+
while (dragIdx < st.data.length - 1 && shouldSwapDown(pointerLast.y, st.data[dragIdx + 1]!.y.dest, st.data[dragIdx + 1]!.sizeY)) {
|
|
221
|
+
[st.data[dragIdx], st.data[dragIdx + 1]] = [st.data[dragIdx + 1]!, st.data[dragIdx]!] // swap
|
|
222
|
+
dragIdx++
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const rowTops = stackLayout(st.data.map(d => d.sizeY), windowPaddingTop)
|
|
226
|
+
for (let rowIndex = 0; rowIndex < st.data.length; rowIndex++) {
|
|
227
|
+
const d = st.data[rowIndex]!
|
|
228
|
+
const top = rowTops[rowIndex]!
|
|
229
|
+
if (newDragged != null && d.id === newDragged.id) { // already modified above
|
|
230
|
+
} else {
|
|
231
|
+
d.x.dest = center(rowSizeX, windowSizeX)
|
|
232
|
+
d.y.dest = top
|
|
233
|
+
d.scale.dest = 1
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const cursor =
|
|
237
|
+
newDragged != null ? 'grabbing' // will be "grabbing" even if cursor isn't on the card! Try dragging to left/right extremes
|
|
238
|
+
: hitTest(st.data, pointerLast) != null ? 'grab'
|
|
239
|
+
: 'auto'
|
|
240
|
+
|
|
241
|
+
// === step 4: run animation
|
|
242
|
+
let newAnimatedUntilTime = st.animatedUntilTime ?? now
|
|
243
|
+
const steps = Math.min(300, Math.floor((now - newAnimatedUntilTime) / msPerAnimationStep)) // run x spring steps. Decouple physics simulation from framerate!
|
|
244
|
+
newAnimatedUntilTime += steps * msPerAnimationStep
|
|
245
|
+
let stillAnimating = false
|
|
246
|
+
springForEach(s => {
|
|
247
|
+
for (let i = 0; i < steps; i++) springStep(s)
|
|
248
|
+
if (Math.abs(s.v) < 0.01 && Math.abs(s.dest - s.pos) < 0.01) springGoToEnd(s) // close enough, done
|
|
249
|
+
else stillAnimating = true
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// === step 5: render. Batch DOM writes (dynamic properties only)
|
|
253
|
+
for (let i = 0; i < st.data.length; i++) {
|
|
254
|
+
const d = st.data[i]!, style = domCache[Number(d.id)]!.style
|
|
255
|
+
style.transform = `translate3d(${d.x.pos}px,${d.y.pos}px,0) scale(${d.scale.pos})`
|
|
256
|
+
style.zIndex =
|
|
257
|
+
newDragged != null && d.id === newDragged.id ? String(st.data.length + 2)
|
|
258
|
+
: st.lastDragged != null && d.id === st.lastDragged.id ? String(st.data.length + 1) // last dragged and released row still needs to animate into place; keep it high
|
|
259
|
+
: String(i)
|
|
260
|
+
if (newDragged != null && d.id === newDragged.id) {
|
|
261
|
+
style.boxShadow = "rgba(0, 0, 0, 0.2) 0px 16px 32px 0px"
|
|
262
|
+
style.opacity = '0.7'
|
|
263
|
+
} else {
|
|
264
|
+
style.boxShadow = "rgba(0, 0, 0, 0.2) 0px 1px 2px 0px"
|
|
265
|
+
style.opacity = '1.0'
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
document.body.style.cursor = cursor
|
|
269
|
+
|
|
270
|
+
// === step 6: update state & prepare for next frame
|
|
271
|
+
if (st.pointerState === 'firstDown') st.pointerState = 'down'
|
|
272
|
+
if (st.dragged != null && newDragged == null) st.lastDragged = st.dragged
|
|
273
|
+
st.dragged = newDragged
|
|
274
|
+
st.animatedUntilTime = stillAnimating ? newAnimatedUntilTime : null // oxlint-disable-line no-unnecessary-condition
|
|
275
|
+
if (st.pointerState === 'up') st.pointer = [{x: 0, y: 0, time: 0}]
|
|
276
|
+
if (st.pointer.length > 20) st.pointer.shift() // keep last ~20
|
|
277
|
+
st.events.mouseup = st.events.touchend = st.events.touchcancel = st.events.mousemove = st.events.touchmove = st.events.pointerdown = null
|
|
278
|
+
|
|
279
|
+
return stillAnimating // oxlint-disable-line no-unnecessary-condition
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
scheduleRender()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Pure swap and stack geometry for draggable-cards.
|
|
2
|
+
// The demo imports it.
|
|
3
|
+
|
|
4
|
+
export const rowSizeX = 320
|
|
5
|
+
export const windowPaddingTop = 50
|
|
6
|
+
|
|
7
|
+
/** @fit
|
|
8
|
+
* given containee: 0..<Infinity
|
|
9
|
+
* given container: 0..<Infinity
|
|
10
|
+
* return == (container - containee) / 2
|
|
11
|
+
*/
|
|
12
|
+
export function center(containee: number, container: number) {
|
|
13
|
+
return (container - containee) / 2
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function shouldSwapUp(pointerY: number, neighborDestY: number, neighborSizeY: number) {
|
|
17
|
+
return pointerY < neighborDestY + neighborSizeY / 2
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function shouldSwapDown(pointerY: number, neighborDestY: number, neighborSizeY: number) {
|
|
21
|
+
return pointerY > neighborDestY + neighborSizeY / 2
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @fit
|
|
25
|
+
* given rowSizes.length: int 0..100
|
|
26
|
+
* given rowSizes[]: 0..1000
|
|
27
|
+
* return.length == rowSizes.length
|
|
28
|
+
*/
|
|
29
|
+
export function stackLayout(
|
|
30
|
+
rowSizes: number[],
|
|
31
|
+
paddingTop: number, // @fit 0..1000
|
|
32
|
+
): number[] {
|
|
33
|
+
const result: number[] = []
|
|
34
|
+
let top = paddingTop
|
|
35
|
+
for (const rowSize of rowSizes) {
|
|
36
|
+
result.push(top)
|
|
37
|
+
top += rowSize
|
|
38
|
+
}
|
|
39
|
+
return result
|
|
40
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<head>
|
|
3
|
+
<meta charset="utf-8" />
|
|
4
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
5
|
+
<title>PiP Corner Snap (Two-Spring)</title>
|
|
6
|
+
<style>
|
|
7
|
+
body {
|
|
8
|
+
font: 14px system-ui;
|
|
9
|
+
user-select: none;
|
|
10
|
+
-webkit-user-select: none;
|
|
11
|
+
background-color: #1a1a2e;
|
|
12
|
+
overflow: hidden;
|
|
13
|
+
margin: 0;
|
|
14
|
+
height: 100vh;
|
|
15
|
+
}
|
|
16
|
+
.pip {
|
|
17
|
+
position: absolute;
|
|
18
|
+
width: 120px;
|
|
19
|
+
height: 80px;
|
|
20
|
+
border-radius: 12px;
|
|
21
|
+
background: #e94560;
|
|
22
|
+
display: grid;
|
|
23
|
+
place-items: center;
|
|
24
|
+
color: white;
|
|
25
|
+
font-weight: 600;
|
|
26
|
+
font-size: 13px;
|
|
27
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
|
28
|
+
}
|
|
29
|
+
.corner-marker {
|
|
30
|
+
position: absolute;
|
|
31
|
+
width: 16px;
|
|
32
|
+
height: 16px;
|
|
33
|
+
border-radius: 50%;
|
|
34
|
+
background: rgba(255,255,255,0.4);
|
|
35
|
+
transform: translate(-50%, -50%);
|
|
36
|
+
}
|
|
37
|
+
.rest-marker {
|
|
38
|
+
position: absolute;
|
|
39
|
+
width: 10px;
|
|
40
|
+
height: 10px;
|
|
41
|
+
border-radius: 50%;
|
|
42
|
+
background: rgba(100,200,255,0.85);
|
|
43
|
+
transform: translate(-50%, -50%);
|
|
44
|
+
pointer-events: none;
|
|
45
|
+
}
|
|
46
|
+
#info {
|
|
47
|
+
position: absolute;
|
|
48
|
+
bottom: 20px;
|
|
49
|
+
left: 20px;
|
|
50
|
+
color: rgba(255,255,255,0.5);
|
|
51
|
+
font-size: 12px;
|
|
52
|
+
line-height: 1.6;
|
|
53
|
+
}
|
|
54
|
+
</style>
|
|
55
|
+
</head>
|
|
56
|
+
<body>
|
|
57
|
+
<div id="info">
|
|
58
|
+
Two-spring corner snap. Drag and flick the card toward a corner.<br>
|
|
59
|
+
Decay rest = <span style="color:rgba(100,200,255,0.85)">blue dot</span> ·
|
|
60
|
+
Corners = <span style="color:rgba(255,255,255,0.5)">white dots</span>
|
|
61
|
+
</div>
|
|
62
|
+
<script src="./index.ts" type="module"></script>
|
|
63
|
+
</body>
|