├── images
├── twitter.png
└── facebook.png
├── index.js
├── .gitignore
├── README.md
├── index.css
├── index.html
└── picknplace.js
/images/twitter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jgthms/picknplace.js/HEAD/images/twitter.png
--------------------------------------------------------------------------------
/images/facebook.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jgthms/picknplace.js/HEAD/images/facebook.png
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import { createPickPlace } from "./picknplace.js";
2 |
3 | const pnp = createPickPlace();
4 | pnp.init();
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 | _site
26 | .jekyll-cache
27 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # picknplace.js
2 |
3 | A proof of concept of a viable drag and drop alternative.
4 |
5 |
6 |
7 | ### Why?
8 |
9 | I find that the drag and drop experience can quickly become a nightmare, especially on mobile.
10 | Trying to tap, hold, drag, and scroll, all at the _same time_, is awkward, slow, and error-prone.
11 | I've long had in mind a simpler 2-step approach: picking an item first, _then_ placing it.
12 | So I implemented this basic version to showcase my idea.
13 |
14 | ### How it works
15 |
16 | When picking an item, a duplicate of the list is created on top of the original one.
17 | The duplicate is interactive and animated, and will update based on the scroll position.
18 | At the end, the user can either confirm or cancel the changes.
19 |
20 | ### Is this a library?
21 |
22 | Not exactly. This is merely a proof of concept, to convey what I had in mind.
23 | You can however look at the source code, for inspiration.
24 |
--------------------------------------------------------------------------------
/index.css:
--------------------------------------------------------------------------------
1 | /*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */
2 | html,
3 | body,
4 | p,
5 | ol,
6 | ul,
7 | li,
8 | dl,
9 | dt,
10 | dd,
11 | blockquote,
12 | figure,
13 | fieldset,
14 | legend,
15 | textarea,
16 | pre,
17 | iframe,
18 | hr,
19 | h1,
20 | h2,
21 | h3,
22 | h4,
23 | h5,
24 | h6 {
25 | margin: 0;
26 | padding: 0;
27 | }
28 | h1,
29 | h2,
30 | h3,
31 | h4,
32 | h5,
33 | h6 {
34 | font-size: 100%;
35 | font-weight: normal;
36 | }
37 | ul {
38 | list-style: none;
39 | }
40 | button,
41 | input,
42 | select {
43 | margin: 0;
44 | }
45 | html {
46 | box-sizing: border-box;
47 | }
48 | *,
49 | *::before,
50 | *::after {
51 | box-sizing: inherit;
52 | }
53 | img,
54 | video {
55 | height: auto;
56 | max-width: 100%;
57 | }
58 | iframe {
59 | border: 0;
60 | }
61 | table {
62 | border-collapse: collapse;
63 | border-spacing: 0;
64 | }
65 | td,
66 | th {
67 | padding: 0;
68 | }
69 |
70 | :root {
71 | --yellow: #fabd1b;
72 | --orange: #ff8411;
73 | --crimson: #ff6738;
74 | --pink: #ff7e75;
75 | --purple: #ae9eff;
76 | --cyan: #18c6f6;
77 | --blue: #5294ff;
78 | --teal: #00c2a8;
79 | --green: #00d66b;
80 | --lime: #b3db00;
81 | --keyboardfocus-color: #000;
82 | }
83 |
84 | html {
85 | font-family: Poppins, Inter, "Inter Variable", system-ui, sans-serif;
86 | line-height: 1.5;
87 | font-weight: 400;
88 |
89 | background-color: #fcf5ef;
90 | color: #999999;
91 |
92 | font-synthesis: none;
93 | text-rendering: optimizeLegibility;
94 | -webkit-font-smoothing: antialiased;
95 | -moz-osx-font-smoothing: grayscale;
96 |
97 | padding: 0 3em;
98 | }
99 |
100 | body,
101 | header,
102 | footer,
103 | main,
104 | .pnp-list {
105 | display: flex;
106 | flex-direction: column;
107 | justify-content: center;
108 | }
109 |
110 | h1,
111 | strong,
112 | em,
113 | button {
114 | color: #111;
115 | font-weight: 500;
116 | }
117 |
118 | em {
119 | font-style: normal;
120 | }
121 |
122 | header,
123 | footer {
124 | gap: 1em;
125 | min-height: 86vh;
126 | padding: 6em 0;
127 | }
128 |
129 | footer {
130 | gap: 3em;
131 |
132 | p:not(:first-child) {
133 | color: #666;
134 | margin-top: 0.25em;
135 | }
136 | }
137 |
138 | a,
139 | button {
140 | color: inherit;
141 | cursor: pointer;
142 | text-decoration: none;
143 | }
144 |
145 | button:hover {
146 | text-decoration: underline;
147 | }
148 |
149 | a {
150 | border-bottom: 2px solid var(--yellow);
151 | color: #111;
152 | transition-duration: 200ms;
153 | transition-property: background-color;
154 |
155 | &:hover {
156 | background-color: var(--yellow);
157 | }
158 | }
159 |
160 | .accent {
161 | color: var(--yellow);
162 | }
163 |
164 | .accent-bg {
165 | strong {
166 | background-color: var(--yellow);
167 | display: inline-flex;
168 | padding: 0.125em 0.5em;
169 | border-radius: 0.5em;
170 | }
171 | }
172 |
173 | kbd {
174 | background-color: var(--yellow);
175 | background-color: rgba(0, 0, 0, 0.1);
176 | color: #333;
177 | display: inline-flex;
178 | font-family: inherit;
179 | padding: 0.125em 0.5em;
180 | border-radius: 0.5em;
181 | }
182 |
183 | body {
184 | align-items: center;
185 | }
186 |
187 | button {
188 | appearance: none;
189 | background: none;
190 | border: none;
191 | color: #111;
192 | outline-color: transparent;
193 | outline-width: 0;
194 | font-family: inherit;
195 | font-weight: 500;
196 | font-size: 1em;
197 | line-height: inherit;
198 | margin: 0;
199 | padding: 0;
200 | }
201 |
202 | main {
203 | align-items: stretch;
204 | flex-grow: 1;
205 | max-width: 21em;
206 | width: 100%;
207 | gap: 0;
208 | }
209 |
210 | h1 {
211 | font-size: 1.5em;
212 | font-weight: 600;
213 | letter-spacing: -0.04em;
214 | }
215 |
216 | .pnp-list {
217 | --spacing: 0.5em;
218 | padding: var(--spacing);
219 | gap: var(--spacing);
220 | background-color: #fff;
221 | align-items: stretch;
222 | border-radius: 1em;
223 | list-style: none;
224 | position: relative;
225 | }
226 |
227 | .element {
228 | background-color: var(--color);
229 | display: flex;
230 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.06),
231 | 0 -1px 0 0 rgba(0, 0, 0, 0.1) inset;
232 | border-radius: 0.5em;
233 | padding: 0.5em 0.75em;
234 | justify-content: space-between;
235 | align-items: center;
236 | }
237 |
238 | .pnp-list.is-ready {
239 | .pnp-clone {
240 | transition-duration: 200ms;
241 | transition-property: transform;
242 | transition-timing-function: ease-out;
243 | }
244 | }
245 |
246 | .pnp-picked {
247 | opacity: 0;
248 | }
249 |
250 | .pnp-buttons {
251 | display: flex;
252 | align-items: center;
253 | gap: 0.5em;
254 | justify-content: end;
255 | }
256 |
257 | .pnp-controls {
258 | position: fixed;
259 | z-index: 10;
260 | left: 0;
261 | right: 0;
262 | display: flex;
263 | align-items: center;
264 | padding: 2em;
265 | justify-content: center;
266 | gap: 1em;
267 | top: 100%;
268 | opacity: 0;
269 | transition-duration: 200ms;
270 | transition-property: opacity, transform;
271 | pointer-events: none;
272 | }
273 |
274 | .button {
275 | display: flex;
276 | align-items: center;
277 | justify-content: center;
278 | padding: 0.5em 1em;
279 | border-radius: 9999px;
280 |
281 | &.is-primary {
282 | background-color: #111;
283 | color: #fff;
284 | }
285 | }
286 |
287 | @keyframes animRotate {
288 | from {
289 | transform: none;
290 | }
291 |
292 | to {
293 | transform: scale(1.04) rotate(-4deg);
294 | }
295 | }
296 |
297 | .element {
298 | transform-origin: center;
299 | will-change: transform;
300 | animation-duration: 100ms;
301 | animation-fill-mode: both;
302 | animation-timing-function: ease-out;
303 | }
304 |
305 | .pnp-ghost {
306 | --offset: 0px;
307 | list-style: none;
308 | position: fixed;
309 | left: 0;
310 | top: 0;
311 | opacity: 1;
312 | }
313 |
314 | .pnp-list {
315 | &[data-mode="picking"] {
316 | .pnp-buttons {
317 | opacity: 0;
318 | pointer-events: none;
319 | }
320 | }
321 | }
322 | .element:focus-within {
323 | outline: 2px solid var(--keyboardfocus-color);
324 | outline-offset: 2px;
325 | }
326 | .pnp-list {
327 | &[data-mode="picking"] {
328 | .pnp-item:not(.pnp-clone) {
329 | opacity: 0;
330 | pointer-events: none;
331 | }
332 | }
333 | }
334 |
335 | /* Desktop */
336 | @media (hover: hover) {
337 | }
338 |
339 | /* Mobile */
340 | @media (hover: none) {
341 | small {
342 | display: none;
343 | }
344 |
345 | .pnp-controls {
346 | &.is-active {
347 | opacity: 1;
348 | transform: translateY(-100%);
349 | pointer-events: auto;
350 | }
351 | }
352 |
353 | .pnp-ghost {
354 | .pnp-buttons {
355 | opacity: 0;
356 | pointer-events: none;
357 | }
358 | }
359 | }
360 |
361 | .element.n1 {
362 | --color: var(--yellow);
363 | }
364 | .element.n2 {
365 | --color: var(--orange);
366 | }
367 | .element.n3 {
368 | --color: var(--crimson);
369 | }
370 | .element.n4 {
371 | --color: var(--pink);
372 | }
373 | .element.n5 {
374 | --color: var(--purple);
375 | }
376 | .element.n6 {
377 | --color: var(--cyan);
378 | }
379 | .element.n7 {
380 | --color: var(--blue);
381 | }
382 | .element.n8 {
383 | --color: var(--teal);
384 | }
385 | .element.n9 {
386 | --color: var(--green);
387 | }
388 | .element.n10 {
389 | --color: var(--lime);
390 | }
391 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
16 |
17 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
31 |
35 |
36 |
37 |
38 |
39 |
43 |
44 |
45 | picknplace.js, an alternative to drag and drop
46 |
47 |
48 |
49 |
50 |
65 |
66 |
67 | -
68 |
69 |
One
70 |
71 |
72 |
73 |
74 |
75 | -
76 |
77 |
Two
78 |
79 |
80 |
81 |
82 |
83 | -
84 |
85 |
Three
86 |
87 |
88 |
89 |
90 |
91 | -
92 |
93 |
Four
94 |
95 |
96 |
97 |
98 |
99 | -
100 |
101 |
Five
102 |
103 |
104 |
105 |
106 |
107 | -
108 |
109 |
Six
110 |
111 |
112 |
113 |
114 |
115 | -
116 |
117 |
Seven
118 |
119 |
120 |
121 |
122 |
123 | -
124 |
125 |
Eight
126 |
127 |
128 |
129 |
130 |
131 | -
132 |
133 |
Nine
134 |
135 |
136 |
137 |
138 |
139 | -
140 |
141 |
Ten
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
214 |
215 |
216 |
217 |
--------------------------------------------------------------------------------
/picknplace.js:
--------------------------------------------------------------------------------
1 | export function createPickPlace(options = {}) {
2 | const root = options.root || document;
3 | const listSelector = options.listSelector || ".pnp-list";
4 | const itemSelector = options.itemSelector || ".pnp-item";
5 | const controlsSelector = options.controlsSelector || ".pnp-controls";
6 | const buttonsSelector = options.buttonsSelector || ".pnp-buttons";
7 | const pickSelector = options.pickSelector || ".pnp-pick";
8 | const placeSelector = options.placeSelector || ".pnp-place";
9 | const cancelSelector = options.cancelSelector || ".pnp-cancel";
10 | const pickedClass = options.pickedClass || "pnp-picked";
11 | const realClass = options.realClass || "pnp-real";
12 | const ghostClass = options.ghostClass || "pnp-ghost";
13 | const cloneClass = options.cloneClass || "pnp-clone";
14 |
15 | let initialized = false;
16 | let $ghost = null;
17 | let scrollDirY = 0;
18 | let lastScrollY = window.scrollY;
19 | let targetIndex = null;
20 | let $controls = null;
21 | let ghostTop = 0;
22 | let ghostOffset = 0;
23 |
24 | // State
25 | let state = {
26 | mode: "idle",
27 | $list: null,
28 | $item: null,
29 | originalTop: 0,
30 | positions: [],
31 | currentIndex: null,
32 | };
33 |
34 | const reduce = (state, event) => {
35 | switch (event.type) {
36 | case "pick":
37 | return {
38 | mode: "picking",
39 | $item: event.$item,
40 | $list: event.$list,
41 | originalTop: event.originalTop,
42 | positions: event.positions,
43 | currentIndex: event.currentIndex,
44 | };
45 |
46 | case "place":
47 | return {
48 | mode: "idle",
49 | $list: null,
50 | $item: null,
51 | originalTop: 0,
52 | positions: [],
53 | currentIndex: null,
54 | };
55 |
56 | case "cancel":
57 | return {
58 | mode: "idle",
59 | $list: null,
60 | $item: null,
61 | originalTop: 0,
62 | positions: [],
63 | currentIndex: null,
64 | };
65 |
66 | default:
67 | return state;
68 | }
69 | };
70 |
71 | const dispatch = (event) => {
72 | const prev = state;
73 | const next = reduce(state, event);
74 |
75 | if (next === prev) {
76 | return;
77 | }
78 |
79 | // Store and reset
80 | state = next;
81 | targetIndex = null;
82 |
83 | // Update the DOM
84 | const $list = prev.$list || next.$list;
85 |
86 | if ($list) {
87 | $list.dataset.mode = next.mode;
88 | }
89 |
90 | if (next.mode === "picking") {
91 | next.$item?.classList.add(pickedClass);
92 | setPickingMode();
93 | next.$list?.classList.add("is-ready");
94 | createGhost(next.$item);
95 |
96 | if ($controls) {
97 | $controls.classList.add("is-active");
98 | }
99 | }
100 |
101 | if (next.mode === "idle") {
102 | prev.$item?.classList.remove(pickedClass);
103 |
104 | if (prev.$list) {
105 | prev.$list.classList.remove("is-ready");
106 | setIdleMode(prev.$list);
107 | }
108 |
109 | if ($controls) {
110 | $controls.classList.remove("is-active");
111 | }
112 |
113 | destroyGhost();
114 | }
115 |
116 | if (event.type === "place") {
117 | sortDomByNewIndices(prev.$list, prev.positions);
118 | }
119 | };
120 |
121 | // DOM
122 | const sortDomByNewIndices = ($list, positions) => {
123 | const ordered = positions
124 | .slice()
125 | .sort((a, b) => a.currentIndex - b.currentIndex);
126 |
127 | const frag = document.createDocumentFragment();
128 | for (const p of ordered) frag.appendChild(p.el);
129 |
130 | $list.appendChild(frag);
131 | };
132 |
133 | // Ghost
134 | const createGhost = (item) => {
135 | destroyGhost();
136 |
137 | const clone = item.cloneNode(true);
138 | clone.classList.add(ghostClass);
139 |
140 | const rect = item.getBoundingClientRect();
141 |
142 | Object.assign(clone.style, {
143 | position: "fixed",
144 | width: `${rect.width}px`,
145 | height: `${rect.height}px`,
146 | transform: `translate(${rect.left}px, calc(${rect.top}px + var(--offset))`,
147 | });
148 | ghostTop = rect.top;
149 |
150 | const buttons = clone.querySelector(buttonsSelector);
151 |
152 | if (buttons) {
153 | buttons.innerHTML = `
154 |
155 |
156 | `;
157 | }
158 |
159 | document.body.appendChild(clone);
160 | $ghost = clone;
161 | };
162 |
163 | const destroyGhost = () => {
164 | if ($ghost) {
165 | $ghost.remove();
166 | }
167 |
168 | $ghost = null;
169 | };
170 |
171 | // Events
172 | const onKeyDown = (event) => {
173 | if (event.key === "Escape") {
174 | return dispatch({
175 | type: "cancel",
176 | });
177 | }
178 |
179 | if (event.key === "Enter") {
180 | return dispatch({
181 | type: "place",
182 | });
183 | }
184 | };
185 |
186 | const onClick = (event) => {
187 | const target = event.target;
188 |
189 | if (!(target instanceof Element)) {
190 | return;
191 | }
192 |
193 | const cancelBtn = target.closest(cancelSelector);
194 |
195 | if (cancelBtn) {
196 | return dispatch({
197 | type: "cancel",
198 | });
199 | }
200 |
201 | const placeBtn = target.closest(placeSelector);
202 |
203 | if (placeBtn) {
204 | return dispatch({
205 | type: "place",
206 | });
207 | }
208 |
209 | const pickBtn = target.closest(pickSelector);
210 |
211 | if (pickBtn) {
212 | event.preventDefault();
213 | event.stopPropagation();
214 |
215 | if (state.mode === "picking") {
216 | return dispatch({
217 | type: "cancel",
218 | });
219 | }
220 |
221 | const $item = pickBtn.closest(itemSelector);
222 | const $list = $item?.closest(listSelector);
223 |
224 | if (!$item || !$list) {
225 | return;
226 | }
227 |
228 | const listRect = $list.getBoundingClientRect();
229 |
230 | const $items = Array.from($list.children);
231 | const currentIndex = $items.indexOf($item);
232 |
233 | const positions = $items.map((el, index) => {
234 | const rect = el.getBoundingClientRect();
235 |
236 | return {
237 | el,
238 | clone: null,
239 | originalIndex: index,
240 | currentIndex: index,
241 | originalTop: rect.top,
242 | rect,
243 | };
244 | });
245 |
246 | return dispatch({
247 | type: "pick",
248 | $list,
249 | $item,
250 | originalTop: listRect.top,
251 | positions,
252 | currentIndex,
253 | });
254 | }
255 | };
256 |
257 | const swapByIndex = (positions, indexA, indexB) => {
258 | if (indexA === indexB) return;
259 |
260 | const a = positions.find((p) => p.currentIndex === indexA);
261 | const b = positions.find((p) => p.currentIndex === indexB);
262 |
263 | if (!a || !b) return;
264 |
265 | a.currentIndex = indexB;
266 | b.currentIndex = indexA;
267 | };
268 |
269 | let scrollRaf = null;
270 |
271 | const onScroll = (event) => {
272 | if (state.mode !== "picking" || !$ghost || scrollRaf) {
273 | return;
274 | }
275 |
276 | const y = window.scrollY;
277 | scrollDirY = y - lastScrollY;
278 | lastScrollY = y;
279 |
280 | scrollRaf = requestAnimationFrame(() => {
281 | scrollRaf = null;
282 |
283 | const ghostRect = $ghost.getBoundingClientRect();
284 | const ghostCenter = {
285 | x: ghostRect.left + ghostRect.width / 2,
286 | y: ghostRect.top + ghostRect.height / 2,
287 | };
288 |
289 | const $items = Array.from(state.$list.children).filter(
290 | (x) => !x.classList.contains(cloneClass)
291 | );
292 |
293 | let newTargetIndex;
294 |
295 | const listRect = state.$list.getBoundingClientRect();
296 | const listSpacing = parseFloat(getComputedStyle(state.$list).paddingTop);
297 |
298 | // If the ghost goes above the list
299 | if (listRect.top + listSpacing > ghostTop) {
300 | ghostOffset = listRect.top - ghostTop + listSpacing;
301 | } else if (
302 | ghostTop >
303 | listRect.top + listRect.height - ghostRect.height - listSpacing
304 | ) {
305 | const diff =
306 | listRect.top + listRect.height - ghostRect.height - listSpacing;
307 | ghostOffset = -1 * ghostTop + diff;
308 | } else {
309 | ghostOffset = 0;
310 | }
311 |
312 | $ghost.style.setProperty("--offset", `${ghostOffset}px`);
313 |
314 | if (ghostCenter.y > listRect.top + listRect.height) {
315 | newTargetIndex = $items.length - 1;
316 | } else if (scrollDirY >= 0) {
317 | // Going down: swap when center crosses the top edge
318 | for (const [index, $item] of $items.entries()) {
319 | const rect = $item.getBoundingClientRect();
320 |
321 | if (ghostCenter.y < rect.top) {
322 | newTargetIndex = index - 1;
323 | break;
324 | }
325 |
326 | newTargetIndex = $items.length - 1;
327 | }
328 | } else {
329 | // Going up: swap when center crosses the bottom edge
330 | for (const [index, $item] of $items.entries()) {
331 | const rect = $item.getBoundingClientRect();
332 |
333 | if (ghostCenter.y < rect.bottom) {
334 | newTargetIndex = index;
335 | break;
336 | }
337 |
338 | newTargetIndex = 0;
339 | }
340 | }
341 |
342 | if (newTargetIndex !== targetIndex) {
343 | swapByIndex(state.positions, targetIndex, newTargetIndex);
344 | transformItems();
345 | targetIndex = newTargetIndex;
346 | }
347 | });
348 | };
349 |
350 | // Modes
351 | const setPickingMode = () => {
352 | const listRect = state.$list.getBoundingClientRect();
353 |
354 | for (const position of state.positions) {
355 | const { el, rect } = position;
356 | const clone = el.cloneNode(true);
357 | clone.classList.remove(realClass);
358 | clone.classList.add(cloneClass);
359 | position.clone = clone;
360 |
361 | Object.assign(clone.style, {
362 | position: "absolute",
363 | left: "0px",
364 | top: "0px",
365 | width: `${rect.width}px`,
366 | transform: `translate(
367 | ${rect.left - listRect.left}px,
368 | ${rect.top - listRect.top}px
369 | )`,
370 | });
371 |
372 | state.$list.appendChild(clone);
373 | }
374 | };
375 |
376 | const transformItems = () => {
377 | const listRect = state.$list.getBoundingClientRect();
378 |
379 | for (const position of state.positions) {
380 | const { clone, currentIndex, rect } = position;
381 |
382 | let top = rect.top;
383 |
384 | const found = state.positions.find(
385 | (pos) => currentIndex === pos.originalIndex
386 | );
387 |
388 | if (found && found.originalTop) {
389 | top = found.originalTop;
390 | }
391 |
392 | Object.assign(clone.style, {
393 | transform: `translate(
394 | ${rect.left - listRect.left}px,
395 | ${top - state.originalTop}px
396 | )`,
397 | });
398 | }
399 | };
400 |
401 | const setIdleMode = ($list) => {
402 | const clones = $list.querySelectorAll(`.${cloneClass}`);
403 |
404 | for (const clone of clones) {
405 | clone.remove();
406 | }
407 | };
408 |
409 | // Lifecyle
410 | const init = () => {
411 | if (initialized) {
412 | return;
413 | }
414 |
415 | $controls = root.querySelector(controlsSelector);
416 | root.addEventListener("click", onClick, true);
417 | root.addEventListener("keydown", onKeyDown, true);
418 | window.addEventListener("scroll", onScroll, { passive: true });
419 |
420 | initialized = true;
421 | };
422 |
423 | return {
424 | init,
425 | };
426 | }
427 |
--------------------------------------------------------------------------------