From 1e8350964fdfc9eb487669f11d8fafc5ac0125ac Mon Sep 17 00:00:00 2001 From: andylovescode Date: Fri, 29 Aug 2025 11:44:29 -0700 Subject: [PATCH 1/4] Layout animations: V1 --- packages/example/src/client.tsx | 188 +++++++++++++++---------- packages/example/src/index.html | 1 + packages/example/src/style.css | 32 +++++ packages/example/tsconfig.json | 42 +++--- packages/vortex-move/src/index.ts | 2 + packages/vortex-move/src/layout.ts | 146 +++++++++++++++++++ packages/vortex-move/src/projection.ts | 54 +++++++ packages/vortex-move/src/scheduler.ts | 3 +- packages/vortex-move/src/spring.ts | 160 ++++++++++++--------- 9 files changed, 473 insertions(+), 155 deletions(-) create mode 100644 packages/example/src/style.css create mode 100644 packages/vortex-move/src/layout.ts create mode 100644 packages/vortex-move/src/projection.ts diff --git a/packages/example/src/client.tsx b/packages/example/src/client.tsx index 41dd822..3329e5f 100644 --- a/packages/example/src/client.tsx +++ b/packages/example/src/client.tsx @@ -1,94 +1,138 @@ import { - createContext, - getImmediateValue, - list, - render, - useDerived, - useState, - when, + createContext, + getImmediateValue, + list, + render, + useDerived, + useHookLifetime, + useState, + when, } from "@vortexjs/core"; import { html } from "@vortexjs/dom"; -import { useSpring } from "@vortexjs/move"; +import { layout } from "@vortexjs/move"; const TestingContext = createContext("TestingContext"); function TestingComponent() { - const ctxData = TestingContext.use(); + const ctxData = TestingContext.use(); - return

This is a testing component. Context data: {ctxData}

; + return

This is a testing component. Context data: {ctxData}

; } -function SpringSliders() { - const targetValue = useState(0); - const spring = useSpring(targetValue); - const height = useDerived((get) => `${get(spring.signal)}px`); - const width = useDerived((get) => `${10000 / get(spring.signal)}px`); +function LayoutTest() { + const targetValue = useState(0); + const lt = useHookLifetime(); - return ( - <> - -
- - ); + return ( + <> +
+ +
+
+ {list( + "Fugiat reprehenderit occaecat aute id esse enim ea labore do minim amet velit deserunt exercitation. Minim esse voluptate fugiat est non et fugiat amet duis.".split( + " ", + ), + ).show((x, _i) => ( + <> + + {x} + + + + ))} +
+ + ); +} + +function PopupTest() { + const isOpen = useState(false); + + return ( + <> + + {when(isOpen, () => ( +
+ This is a popup! +
+ ))} + + ); } function App() { - const counter = useState(0); - const name = useState("multiverse"); + const counter = useState(0); + const name = useState("multiverse"); - const numbersToCounter = useDerived((get) => { - const currentCounter = get(counter); - return Array.from({ length: currentCounter }, (_, i) => i + 1); - }); + const numbersToCounter = useDerived((get) => { + const currentCounter = get(counter); + return Array.from({ length: currentCounter }, (_, i) => i + 1); + }); - return ( - <> - - - -

- Counter = {counter}, Name = {name} -

- - + return ( + <> + + + +

+ Counter = {counter}, Name = {name} +

+ + - {when( - useDerived((get) => get(counter) % 2 === 0), - () => ( -

{counter} is an even number

- ), - )} + {when( + useDerived((get) => get(counter) % 2 === 0), + () => ( +

{counter} is an even number

+ ), + )} - {list(numbersToCounter).show((number) => ( -

- {number} is a number from 1 to {counter} -

- ))} + {list(numbersToCounter).show((number) => ( +

+ {number} is a number from 1 to {counter} +

+ ))} - - - ); + + + + ); } render(html(), document.body, ); diff --git a/packages/example/src/index.html b/packages/example/src/index.html index 5c85715..5e6bdad 100644 --- a/packages/example/src/index.html +++ b/packages/example/src/index.html @@ -4,6 +4,7 @@ Bun + React +
diff --git a/packages/example/src/style.css b/packages/example/src/style.css new file mode 100644 index 0000000..8eb0867 --- /dev/null +++ b/packages/example/src/style.css @@ -0,0 +1,32 @@ +button { + padding: 10px; +} + +.resizer { + padding: 30px; + border: 1px solid #ccc; + resize: both; + font-size: 200%; + width: 500px; + overflow: auto; +} + +.inline-char { + display: inline-block; +} + +.popup { + padding: 30px; + background: #f0f0f0; + border: 1px solid #ccc; + width: 50%; + margin: 20px; + transition: opacity, filter ease-out 0.2s; +} + +@starting-style { + .popup { + opacity: 0; + filter: blur(40px); + } +} diff --git a/packages/example/tsconfig.json b/packages/example/tsconfig.json index 10f8f36..8e4ff77 100644 --- a/packages/example/tsconfig.json +++ b/packages/example/tsconfig.json @@ -1,19 +1,27 @@ { - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "lib": ["ESNext", "DOM"], - "moduleResolution": "bundler", - "jsx": "react-jsx", - "jsxImportSource": "@vortexjs/dom", - "allowJs": true, - "strict": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"] - } - }, - "exclude": ["dist", "node_modules"] + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "lib": [ + "ESNext", + "DOM" + ], + "moduleResolution": "bundler", + "jsx": "react-jsx", + "jsxImportSource": "@vortexjs/dom", + "allowJs": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "exclude": [ + "dist", + "node_modules" + ] } diff --git a/packages/vortex-move/src/index.ts b/packages/vortex-move/src/index.ts index 272a95f..4676ed8 100644 --- a/packages/vortex-move/src/index.ts +++ b/packages/vortex-move/src/index.ts @@ -1,2 +1,4 @@ export * from "./scheduler"; export * from "./spring"; +export * from "./projection"; +export * from "./layout"; diff --git a/packages/vortex-move/src/layout.ts b/packages/vortex-move/src/layout.ts new file mode 100644 index 0000000..4f8ba74 --- /dev/null +++ b/packages/vortex-move/src/layout.ts @@ -0,0 +1,146 @@ +import type { Use } from "@vortexjs/core"; +import { projectElementToBox } from "./projection"; +import { type TickProps, useAnimation } from "./scheduler"; +import { Spring, type SpringSettings } from "./spring"; + +function getOffsetOrigin(elm: HTMLElement): HTMLElement { + const pos = elm.computedStyleMap().get("position"); + const overflowX = elm.computedStyleMap().get("overflow-x"); + const overflowY = elm.computedStyleMap().get("overflow-y"); + + if ( + pos === "absolute" || + pos === "fixed" || + overflowX === "scroll" || + overflowX === "auto" || + overflowY === "scroll" || + overflowY === "auto" + ) { + return elm; + } + if (elm.parentElement) { + return getOffsetOrigin(elm.parentElement); + } + return document.body; +} + +export interface LayoutProps { + id?: string; + startsFrom?: string; + spring?: SpringSettings; +} + +interface LayoutNode { + offsetOrigin: HTMLElement; + previousOOX: number; + previousOOY: number; + + top: Spring; + left: Spring; + width: Spring; + height: Spring; +} + +const layoutInformationTable = new Map(); + +async function nextTick() { + const { promise, resolve } = Promise.withResolvers(); + + (window.setImmediate ?? window.setTimeout)(() => { + resolve(); + }); + + await promise; +} + +export function layout(props?: LayoutProps): Use { + const id = props?.id ?? crypto.randomUUID(); + const springSettings = props?.spring; + + return async ({ ref, lt }) => { + await nextTick(); + + let info = layoutInformationTable.get(id); + + if (!info) { + const offsetOrigin = getOffsetOrigin(ref); + const box = ref.getBoundingClientRect(); + const ooBox = offsetOrigin.getBoundingClientRect(); + + info = { + offsetOrigin, + previousOOX: ooBox.left, + previousOOY: ooBox.top, + + top: new Spring(box.top, springSettings), + left: new Spring(box.left, springSettings), + width: new Spring(box.width, springSettings), + height: new Spring(box.height, springSettings), + }; + + layoutInformationTable.set(id, info); + } + + if (props?.startsFrom) { + const startsFromInfo = layoutInformationTable.get(props.startsFrom); + if (startsFromInfo) { + info.top.value = startsFromInfo.top.value; + info.left.value = startsFromInfo.left.value; + info.width.value = startsFromInfo.width.value; + info.height.value = startsFromInfo.height.value; + } + } + + const tick = (props: TickProps) => { + if (!info) return; + + const { left, top, width, height } = info; + + const currentOO = getOffsetOrigin(ref); + + if (currentOO === info.offsetOrigin) { + const ooBox = info.offsetOrigin.getBoundingClientRect(); + const deltaX = ooBox.left - info.previousOOX; + const deltaY = ooBox.top - info.previousOOY; + + left.value += deltaX; + top.value += deltaY; + + info.previousOOX = ooBox.left; + info.previousOOY = ooBox.top; + } else { + info.offsetOrigin = currentOO; + const ooBox = info.offsetOrigin.getBoundingClientRect(); + info.previousOOX = ooBox.left; + info.previousOOY = ooBox.top; + } + + ref.style.transform = ""; + const box = ref.getBoundingClientRect(); + + left.target = box.left; + top.target = box.top; + width.target = box.width; + height.target = box.height; + + left.update(props.dtSeconds); + top.update(props.dtSeconds); + width.update(props.dtSeconds); + height.update(props.dtSeconds); + + projectElementToBox(ref, { + left: left.value, + top: top.value, + width: width.value, + height: height.value, + }); + }; + + useAnimation( + { + impl: tick, + }, + lt, + ); + }; +} diff --git a/packages/vortex-move/src/projection.ts b/packages/vortex-move/src/projection.ts new file mode 100644 index 0000000..e941b8b --- /dev/null +++ b/packages/vortex-move/src/projection.ts @@ -0,0 +1,54 @@ +export interface Box { + top: number; + left: number; + width: number; + height: number; +} + +export function getElementAbsoluteTransform(element: HTMLElement): DOMMatrix { + const style = getComputedStyle(element); + const transform = style.transform || "none"; + const matrix = new DOMMatrix(transform); + + if (element.parentElement) { + matrix.preMultiplySelf(getElementAbsoluteTransform(element.parentElement)); + } + + return matrix; +} + +export function projectElementToBox( + element: HTMLElement, + target: Box +) { + element.style.transform = ""; + + const currentRect = element.getBoundingClientRect(); + + const translateX = ( + target.left + target.width / 2 + ) - (currentRect.left + currentRect.width / 2); + const translateY = ( + target.top + target.height / 2 + ) - (currentRect.top + currentRect.height / 2); + let scaleX = target.width / currentRect.width; + let scaleY = target.height / currentRect.height; + + if (!Number.isFinite(scaleX)) { + scaleX = 1; + } + + if (!Number.isFinite(scaleY)) { + scaleY = 1; + } + + const matrix = new DOMMatrix() + .translate(translateX, translateY) + .scale(scaleX, scaleY); + + if (element.parentElement) { + matrix.preMultiplySelf(getElementAbsoluteTransform(element.parentElement).inverse()); + } + + element.style.transform = matrix.toString(); +} diff --git a/packages/vortex-move/src/scheduler.ts b/packages/vortex-move/src/scheduler.ts index fe3c2c7..cf97dde 100644 --- a/packages/vortex-move/src/scheduler.ts +++ b/packages/vortex-move/src/scheduler.ts @@ -73,9 +73,8 @@ export class Scheduler { const sceduler = new Scheduler(); -export function useAnimation(callback: SchedulerCallback) { +export function useAnimation(callback: SchedulerCallback, lt = useHookLifetime()) { const closable = sceduler.addCallback(callback); - const lt = useHookLifetime(); lt.onClosed(() => { closable.close(); diff --git a/packages/vortex-move/src/spring.ts b/packages/vortex-move/src/spring.ts index 4bb3aa8..b53d162 100644 --- a/packages/vortex-move/src/spring.ts +++ b/packages/vortex-move/src/spring.ts @@ -1,74 +1,106 @@ import { - getImmediateValue, - isSignal, - type SignalOrValue, - store, + getImmediateValue, + isSignal, + type SignalOrValue, + store, } from "@vortexjs/core"; import { useAnimation } from "./scheduler"; +export type SpringSettings = { + weight?: number; + speed?: number; + instant?: boolean; +} | undefined; + export class Spring { - target = 0; - value = 0; - velocity = 0; - - // parameters (NOTE: not perfectly realistic) - tension = 100; - reboundFriction = 50; - typicalFriction = 0; - - signal = store(0); - - update(dt: number) { - // Break NaNs - // (shouldn't happen, but just in case) - if (Number.isNaN(this.value)) this.value = 0; - if (Number.isNaN(this.velocity)) this.velocity = 0; - if (Number.isNaN(this.target)) this.target = 0; - - // Move from velocity - this.value += this.velocity * dt; - - // Calculate spring force - const displacement = this.target - this.value; - const springForce = displacement * this.tension; - - if (!Number.isNaN(springForce)) { - this.velocity += springForce * dt; - } - - // Apply friction - const signToTarget = Math.sign(this.target - this.value); - const signVelocity = Math.sign(this.velocity); - const isRebounding = signToTarget !== signVelocity; - const friction = isRebounding - ? this.reboundFriction - : this.typicalFriction; - - // Apply friction in a way that's framerate independent, with framerate independent lerp! - const frictionEffect = 1 / (1 + friction * dt); - - if (!Number.isNaN(frictionEffect)) { - this.velocity *= frictionEffect; - } - - this.signal.set(this.value); - } + target = 0; + value = 0; + velocity = 0; + tension = 0; + reboundFriction = 0; + typicalFriction = 0; + isInstant = false; + + signal = store(0); + + applyConfig( + settings?: SpringSettings, + ) { + this.typicalFriction = 0; + this.reboundFriction = 50 / (settings?.weight ?? 1); + this.tension = 100 * (settings?.speed ?? 1); + this.isInstant = settings?.instant ?? false; + + return this; + } + + constructor(initialValue?: number, settings?: SpringSettings) { + if (initialValue !== undefined) { + this.value = initialValue; + this.target = initialValue; + this.signal.set(initialValue); + } + this.applyConfig(settings); + } + + update(dt: number) { + if (this.isInstant) { + this.value = this.target; + this.velocity = 0; + this.signal.set(this.value); + return; + } + + // Break NaNs + // (shouldn't happen, but just in case) + if (Number.isNaN(this.value)) this.value = 0; + if (Number.isNaN(this.velocity)) this.velocity = 0; + if (Number.isNaN(this.target)) this.target = 0; + + // Move from velocity + this.value += this.velocity * dt; + + // Calculate spring force + const displacement = this.target - this.value; + const springForce = displacement * this.tension; + + if (!Number.isNaN(springForce)) { + this.velocity += springForce * dt; + } + + // Apply friction + const signToTarget = Math.sign(this.target - this.value); + const signVelocity = Math.sign(this.velocity); + const isRebounding = signToTarget !== signVelocity; + const friction = isRebounding + ? this.reboundFriction + : this.typicalFriction; + + // Apply friction in a way that's framerate independent, with framerate independent lerp! + const frictionEffect = 1 / (1 + friction * dt); + + if (!Number.isNaN(frictionEffect)) { + this.velocity *= frictionEffect; + } + + this.signal.set(this.value); + } } export function useSpring( - target: SignalOrValue, - spring = new Spring(), + target: SignalOrValue, + spring = new Spring(), ) { - useAnimation({ - impl: ({ dtSeconds }) => { - const targetValue = isSignal(target) - ? getImmediateValue(target) - : target; - - spring.target = targetValue; - spring.update(dtSeconds); - }, - }); - - return spring; + useAnimation({ + impl: ({ dtSeconds }) => { + const targetValue = isSignal(target) + ? getImmediateValue(target) + : target; + + spring.target = targetValue; + spring.update(dtSeconds); + }, + }); + + return spring; } From 3a60d12cc362fbdc8da90e3cdfad6f11e158fc86 Mon Sep 17 00:00:00 2001 From: andylovescode Date: Fri, 29 Aug 2025 11:46:20 -0700 Subject: [PATCH 2/4] Move: Add changes --- .changeset/rare-ears-wave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/rare-ears-wave.md diff --git a/.changeset/rare-ears-wave.md b/.changeset/rare-ears-wave.md new file mode 100644 index 0000000..5627a3c --- /dev/null +++ b/.changeset/rare-ears-wave.md @@ -0,0 +1,5 @@ +--- +"@vortexjs/move": minor +--- + +Add layout animations From 0a690c49321cdff2bb9faa3bc85658bcc50b42b5 Mon Sep 17 00:00:00 2001 From: andylovescode Date: Fri, 29 Aug 2025 11:46:33 -0700 Subject: [PATCH 3/4] Meta: Reformat --- packages/example/src/client.tsx | 220 +++++++++++----------- packages/example/src/style.css | 38 ++-- packages/vortex-move/src/index.ts | 4 +- packages/vortex-move/src/layout.ts | 246 ++++++++++++------------- packages/vortex-move/src/projection.ts | 27 +-- packages/vortex-move/src/scheduler.ts | 5 +- packages/vortex-move/src/spring.ts | 190 +++++++++---------- 7 files changed, 369 insertions(+), 361 deletions(-) diff --git a/packages/example/src/client.tsx b/packages/example/src/client.tsx index 3329e5f..3786435 100644 --- a/packages/example/src/client.tsx +++ b/packages/example/src/client.tsx @@ -1,12 +1,12 @@ import { - createContext, - getImmediateValue, - list, - render, - useDerived, - useHookLifetime, - useState, - when, + createContext, + getImmediateValue, + list, + render, + useDerived, + useHookLifetime, + useState, + when, } from "@vortexjs/core"; import { html } from "@vortexjs/dom"; import { layout } from "@vortexjs/move"; @@ -14,125 +14,125 @@ import { layout } from "@vortexjs/move"; const TestingContext = createContext("TestingContext"); function TestingComponent() { - const ctxData = TestingContext.use(); + const ctxData = TestingContext.use(); - return

This is a testing component. Context data: {ctxData}

; + return

This is a testing component. Context data: {ctxData}

; } function LayoutTest() { - const targetValue = useState(0); - const lt = useHookLifetime(); + const targetValue = useState(0); + const lt = useHookLifetime(); - return ( - <> -
- -
-
- {list( - "Fugiat reprehenderit occaecat aute id esse enim ea labore do minim amet velit deserunt exercitation. Minim esse voluptate fugiat est non et fugiat amet duis.".split( - " ", - ), - ).show((x, _i) => ( - <> - - {x} - - - - ))} -
- - ); + return ( + <> +
+ +
+
+ {list( + "Fugiat reprehenderit occaecat aute id esse enim ea labore do minim amet velit deserunt exercitation. Minim esse voluptate fugiat est non et fugiat amet duis.".split( + " ", + ), + ).show((x, _i) => ( + <> + + {x} + + + + ))} +
+ + ); } function PopupTest() { - const isOpen = useState(false); + const isOpen = useState(false); - return ( - <> - - {when(isOpen, () => ( -
- This is a popup! -
- ))} - - ); + return ( + <> + + {when(isOpen, () => ( +
+ This is a popup! +
+ ))} + + ); } function App() { - const counter = useState(0); - const name = useState("multiverse"); + const counter = useState(0); + const name = useState("multiverse"); - const numbersToCounter = useDerived((get) => { - const currentCounter = get(counter); - return Array.from({ length: currentCounter }, (_, i) => i + 1); - }); + const numbersToCounter = useDerived((get) => { + const currentCounter = get(counter); + return Array.from({ length: currentCounter }, (_, i) => i + 1); + }); - return ( - <> - - - -

- Counter = {counter}, Name = {name} -

- - + return ( + <> + + + +

+ Counter = {counter}, Name = {name} +

+ + - {when( - useDerived((get) => get(counter) % 2 === 0), - () => ( -

{counter} is an even number

- ), - )} + {when( + useDerived((get) => get(counter) % 2 === 0), + () => ( +

{counter} is an even number

+ ), + )} - {list(numbersToCounter).show((number) => ( -

- {number} is a number from 1 to {counter} -

- ))} + {list(numbersToCounter).show((number) => ( +

+ {number} is a number from 1 to {counter} +

+ ))} - - - - ); + + + + ); } render(html(), document.body, ); diff --git a/packages/example/src/style.css b/packages/example/src/style.css index 8eb0867..a80a54b 100644 --- a/packages/example/src/style.css +++ b/packages/example/src/style.css @@ -1,32 +1,34 @@ button { - padding: 10px; + padding: 10px; } .resizer { - padding: 30px; - border: 1px solid #ccc; - resize: both; - font-size: 200%; - width: 500px; - overflow: auto; + padding: 30px; + border: 1px solid #ccc; + resize: both; + font-size: 200%; + width: 500px; + overflow: auto; } .inline-char { - display: inline-block; + display: inline-block; } .popup { - padding: 30px; - background: #f0f0f0; - border: 1px solid #ccc; - width: 50%; - margin: 20px; - transition: opacity, filter ease-out 0.2s; + padding: 30px; + background: #f0f0f0; + border: 1px solid #ccc; + width: 50%; + margin: 20px; + transition: + opacity, + filter ease-out 0.2s; } @starting-style { - .popup { - opacity: 0; - filter: blur(40px); - } + .popup { + opacity: 0; + filter: blur(40px); + } } diff --git a/packages/vortex-move/src/index.ts b/packages/vortex-move/src/index.ts index 4676ed8..aa51e73 100644 --- a/packages/vortex-move/src/index.ts +++ b/packages/vortex-move/src/index.ts @@ -1,4 +1,4 @@ +export * from "./layout"; +export * from "./projection"; export * from "./scheduler"; export * from "./spring"; -export * from "./projection"; -export * from "./layout"; diff --git a/packages/vortex-move/src/layout.ts b/packages/vortex-move/src/layout.ts index 4f8ba74..ef7da71 100644 --- a/packages/vortex-move/src/layout.ts +++ b/packages/vortex-move/src/layout.ts @@ -4,143 +4,143 @@ import { type TickProps, useAnimation } from "./scheduler"; import { Spring, type SpringSettings } from "./spring"; function getOffsetOrigin(elm: HTMLElement): HTMLElement { - const pos = elm.computedStyleMap().get("position"); - const overflowX = elm.computedStyleMap().get("overflow-x"); - const overflowY = elm.computedStyleMap().get("overflow-y"); - - if ( - pos === "absolute" || - pos === "fixed" || - overflowX === "scroll" || - overflowX === "auto" || - overflowY === "scroll" || - overflowY === "auto" - ) { - return elm; - } - if (elm.parentElement) { - return getOffsetOrigin(elm.parentElement); - } - return document.body; + const pos = elm.computedStyleMap().get("position"); + const overflowX = elm.computedStyleMap().get("overflow-x"); + const overflowY = elm.computedStyleMap().get("overflow-y"); + + if ( + pos === "absolute" || + pos === "fixed" || + overflowX === "scroll" || + overflowX === "auto" || + overflowY === "scroll" || + overflowY === "auto" + ) { + return elm; + } + if (elm.parentElement) { + return getOffsetOrigin(elm.parentElement); + } + return document.body; } export interface LayoutProps { - id?: string; - startsFrom?: string; - spring?: SpringSettings; + id?: string; + startsFrom?: string; + spring?: SpringSettings; } interface LayoutNode { - offsetOrigin: HTMLElement; - previousOOX: number; - previousOOY: number; - - top: Spring; - left: Spring; - width: Spring; - height: Spring; + offsetOrigin: HTMLElement; + previousOOX: number; + previousOOY: number; + + top: Spring; + left: Spring; + width: Spring; + height: Spring; } const layoutInformationTable = new Map(); async function nextTick() { - const { promise, resolve } = Promise.withResolvers(); + const { promise, resolve } = Promise.withResolvers(); - (window.setImmediate ?? window.setTimeout)(() => { - resolve(); - }); + (window.setImmediate ?? window.setTimeout)(() => { + resolve(); + }); - await promise; + await promise; } export function layout(props?: LayoutProps): Use { - const id = props?.id ?? crypto.randomUUID(); - const springSettings = props?.spring; - - return async ({ ref, lt }) => { - await nextTick(); - - let info = layoutInformationTable.get(id); - - if (!info) { - const offsetOrigin = getOffsetOrigin(ref); - const box = ref.getBoundingClientRect(); - const ooBox = offsetOrigin.getBoundingClientRect(); - - info = { - offsetOrigin, - previousOOX: ooBox.left, - previousOOY: ooBox.top, - - top: new Spring(box.top, springSettings), - left: new Spring(box.left, springSettings), - width: new Spring(box.width, springSettings), - height: new Spring(box.height, springSettings), - }; - - layoutInformationTable.set(id, info); - } - - if (props?.startsFrom) { - const startsFromInfo = layoutInformationTable.get(props.startsFrom); - if (startsFromInfo) { - info.top.value = startsFromInfo.top.value; - info.left.value = startsFromInfo.left.value; - info.width.value = startsFromInfo.width.value; - info.height.value = startsFromInfo.height.value; - } - } - - const tick = (props: TickProps) => { - if (!info) return; - - const { left, top, width, height } = info; - - const currentOO = getOffsetOrigin(ref); - - if (currentOO === info.offsetOrigin) { - const ooBox = info.offsetOrigin.getBoundingClientRect(); - const deltaX = ooBox.left - info.previousOOX; - const deltaY = ooBox.top - info.previousOOY; - - left.value += deltaX; - top.value += deltaY; - - info.previousOOX = ooBox.left; - info.previousOOY = ooBox.top; - } else { - info.offsetOrigin = currentOO; - const ooBox = info.offsetOrigin.getBoundingClientRect(); - info.previousOOX = ooBox.left; - info.previousOOY = ooBox.top; - } - - ref.style.transform = ""; - const box = ref.getBoundingClientRect(); - - left.target = box.left; - top.target = box.top; - width.target = box.width; - height.target = box.height; - - left.update(props.dtSeconds); - top.update(props.dtSeconds); - width.update(props.dtSeconds); - height.update(props.dtSeconds); - - projectElementToBox(ref, { - left: left.value, - top: top.value, - width: width.value, - height: height.value, - }); - }; - - useAnimation( - { - impl: tick, - }, - lt, - ); - }; + const id = props?.id ?? crypto.randomUUID(); + const springSettings = props?.spring; + + return async ({ ref, lt }) => { + await nextTick(); + + let info = layoutInformationTable.get(id); + + if (!info) { + const offsetOrigin = getOffsetOrigin(ref); + const box = ref.getBoundingClientRect(); + const ooBox = offsetOrigin.getBoundingClientRect(); + + info = { + offsetOrigin, + previousOOX: ooBox.left, + previousOOY: ooBox.top, + + top: new Spring(box.top, springSettings), + left: new Spring(box.left, springSettings), + width: new Spring(box.width, springSettings), + height: new Spring(box.height, springSettings), + }; + + layoutInformationTable.set(id, info); + } + + if (props?.startsFrom) { + const startsFromInfo = layoutInformationTable.get(props.startsFrom); + if (startsFromInfo) { + info.top.value = startsFromInfo.top.value; + info.left.value = startsFromInfo.left.value; + info.width.value = startsFromInfo.width.value; + info.height.value = startsFromInfo.height.value; + } + } + + const tick = (props: TickProps) => { + if (!info) return; + + const { left, top, width, height } = info; + + const currentOO = getOffsetOrigin(ref); + + if (currentOO === info.offsetOrigin) { + const ooBox = info.offsetOrigin.getBoundingClientRect(); + const deltaX = ooBox.left - info.previousOOX; + const deltaY = ooBox.top - info.previousOOY; + + left.value += deltaX; + top.value += deltaY; + + info.previousOOX = ooBox.left; + info.previousOOY = ooBox.top; + } else { + info.offsetOrigin = currentOO; + const ooBox = info.offsetOrigin.getBoundingClientRect(); + info.previousOOX = ooBox.left; + info.previousOOY = ooBox.top; + } + + ref.style.transform = ""; + const box = ref.getBoundingClientRect(); + + left.target = box.left; + top.target = box.top; + width.target = box.width; + height.target = box.height; + + left.update(props.dtSeconds); + top.update(props.dtSeconds); + width.update(props.dtSeconds); + height.update(props.dtSeconds); + + projectElementToBox(ref, { + left: left.value, + top: top.value, + width: width.value, + height: height.value, + }); + }; + + useAnimation( + { + impl: tick, + }, + lt, + ); + }; } diff --git a/packages/vortex-move/src/projection.ts b/packages/vortex-move/src/projection.ts index e941b8b..cfee2c0 100644 --- a/packages/vortex-move/src/projection.ts +++ b/packages/vortex-move/src/projection.ts @@ -11,26 +11,27 @@ export function getElementAbsoluteTransform(element: HTMLElement): DOMMatrix { const matrix = new DOMMatrix(transform); if (element.parentElement) { - matrix.preMultiplySelf(getElementAbsoluteTransform(element.parentElement)); + matrix.preMultiplySelf( + getElementAbsoluteTransform(element.parentElement), + ); } return matrix; } -export function projectElementToBox( - element: HTMLElement, - target: Box -) { +export function projectElementToBox(element: HTMLElement, target: Box) { element.style.transform = ""; const currentRect = element.getBoundingClientRect(); - const translateX = ( - target.left + target.width / 2 - ) - (currentRect.left + currentRect.width / 2); - const translateY = ( - target.top + target.height / 2 - ) - (currentRect.top + currentRect.height / 2); + const translateX = + target.left + + target.width / 2 - + (currentRect.left + currentRect.width / 2); + const translateY = + target.top + + target.height / 2 - + (currentRect.top + currentRect.height / 2); let scaleX = target.width / currentRect.width; let scaleY = target.height / currentRect.height; @@ -47,7 +48,9 @@ export function projectElementToBox( .scale(scaleX, scaleY); if (element.parentElement) { - matrix.preMultiplySelf(getElementAbsoluteTransform(element.parentElement).inverse()); + matrix.preMultiplySelf( + getElementAbsoluteTransform(element.parentElement).inverse(), + ); } element.style.transform = matrix.toString(); diff --git a/packages/vortex-move/src/scheduler.ts b/packages/vortex-move/src/scheduler.ts index cf97dde..bfbd61f 100644 --- a/packages/vortex-move/src/scheduler.ts +++ b/packages/vortex-move/src/scheduler.ts @@ -73,7 +73,10 @@ export class Scheduler { const sceduler = new Scheduler(); -export function useAnimation(callback: SchedulerCallback, lt = useHookLifetime()) { +export function useAnimation( + callback: SchedulerCallback, + lt = useHookLifetime(), +) { const closable = sceduler.addCallback(callback); lt.onClosed(() => { diff --git a/packages/vortex-move/src/spring.ts b/packages/vortex-move/src/spring.ts index b53d162..5b7999b 100644 --- a/packages/vortex-move/src/spring.ts +++ b/packages/vortex-move/src/spring.ts @@ -1,106 +1,106 @@ import { - getImmediateValue, - isSignal, - type SignalOrValue, - store, + getImmediateValue, + isSignal, + type SignalOrValue, + store, } from "@vortexjs/core"; import { useAnimation } from "./scheduler"; -export type SpringSettings = { - weight?: number; - speed?: number; - instant?: boolean; -} | undefined; +export type SpringSettings = + | { + weight?: number; + speed?: number; + instant?: boolean; + } + | undefined; export class Spring { - target = 0; - value = 0; - velocity = 0; - tension = 0; - reboundFriction = 0; - typicalFriction = 0; - isInstant = false; - - signal = store(0); - - applyConfig( - settings?: SpringSettings, - ) { - this.typicalFriction = 0; - this.reboundFriction = 50 / (settings?.weight ?? 1); - this.tension = 100 * (settings?.speed ?? 1); - this.isInstant = settings?.instant ?? false; - - return this; - } - - constructor(initialValue?: number, settings?: SpringSettings) { - if (initialValue !== undefined) { - this.value = initialValue; - this.target = initialValue; - this.signal.set(initialValue); - } - this.applyConfig(settings); - } - - update(dt: number) { - if (this.isInstant) { - this.value = this.target; - this.velocity = 0; - this.signal.set(this.value); - return; - } - - // Break NaNs - // (shouldn't happen, but just in case) - if (Number.isNaN(this.value)) this.value = 0; - if (Number.isNaN(this.velocity)) this.velocity = 0; - if (Number.isNaN(this.target)) this.target = 0; - - // Move from velocity - this.value += this.velocity * dt; - - // Calculate spring force - const displacement = this.target - this.value; - const springForce = displacement * this.tension; - - if (!Number.isNaN(springForce)) { - this.velocity += springForce * dt; - } - - // Apply friction - const signToTarget = Math.sign(this.target - this.value); - const signVelocity = Math.sign(this.velocity); - const isRebounding = signToTarget !== signVelocity; - const friction = isRebounding - ? this.reboundFriction - : this.typicalFriction; - - // Apply friction in a way that's framerate independent, with framerate independent lerp! - const frictionEffect = 1 / (1 + friction * dt); - - if (!Number.isNaN(frictionEffect)) { - this.velocity *= frictionEffect; - } - - this.signal.set(this.value); - } + target = 0; + value = 0; + velocity = 0; + tension = 0; + reboundFriction = 0; + typicalFriction = 0; + isInstant = false; + + signal = store(0); + + applyConfig(settings?: SpringSettings) { + this.typicalFriction = 0; + this.reboundFriction = 50 / (settings?.weight ?? 1); + this.tension = 100 * (settings?.speed ?? 1); + this.isInstant = settings?.instant ?? false; + + return this; + } + + constructor(initialValue?: number, settings?: SpringSettings) { + if (initialValue !== undefined) { + this.value = initialValue; + this.target = initialValue; + this.signal.set(initialValue); + } + this.applyConfig(settings); + } + + update(dt: number) { + if (this.isInstant) { + this.value = this.target; + this.velocity = 0; + this.signal.set(this.value); + return; + } + + // Break NaNs + // (shouldn't happen, but just in case) + if (Number.isNaN(this.value)) this.value = 0; + if (Number.isNaN(this.velocity)) this.velocity = 0; + if (Number.isNaN(this.target)) this.target = 0; + + // Move from velocity + this.value += this.velocity * dt; + + // Calculate spring force + const displacement = this.target - this.value; + const springForce = displacement * this.tension; + + if (!Number.isNaN(springForce)) { + this.velocity += springForce * dt; + } + + // Apply friction + const signToTarget = Math.sign(this.target - this.value); + const signVelocity = Math.sign(this.velocity); + const isRebounding = signToTarget !== signVelocity; + const friction = isRebounding + ? this.reboundFriction + : this.typicalFriction; + + // Apply friction in a way that's framerate independent, with framerate independent lerp! + const frictionEffect = 1 / (1 + friction * dt); + + if (!Number.isNaN(frictionEffect)) { + this.velocity *= frictionEffect; + } + + this.signal.set(this.value); + } } export function useSpring( - target: SignalOrValue, - spring = new Spring(), + target: SignalOrValue, + spring = new Spring(), ) { - useAnimation({ - impl: ({ dtSeconds }) => { - const targetValue = isSignal(target) - ? getImmediateValue(target) - : target; - - spring.target = targetValue; - spring.update(dtSeconds); - }, - }); - - return spring; + useAnimation({ + impl: ({ dtSeconds }) => { + const targetValue = isSignal(target) + ? getImmediateValue(target) + : target; + + spring.target = targetValue; + spring.update(dtSeconds); + }, + }); + + return spring; } From 36ad3fe4eb6f21a4719ac6904ea23f3d10e26e32 Mon Sep 17 00:00:00 2001 From: andylovescode Date: Fri, 29 Aug 2025 11:50:01 -0700 Subject: [PATCH 4/4] Move: Add spring scaling consistency tests --- packages/vortex-move/package.json | 59 ++++++++++++------------ packages/vortex-move/test/spring.test.ts | 18 ++++++++ 2 files changed, 48 insertions(+), 29 deletions(-) create mode 100644 packages/vortex-move/test/spring.test.ts diff --git a/packages/vortex-move/package.json b/packages/vortex-move/package.json index 7b4375a..35db759 100644 --- a/packages/vortex-move/package.json +++ b/packages/vortex-move/package.json @@ -1,31 +1,32 @@ { - "name": "@vortexjs/move", - "type": "module", - "license": "MIT-0", - "repository": { - "url": "https://github.com/rectangle-run/vortex" - }, - "devDependencies": { - "@types/bun": "catalog:", - "tsdown": "catalog:" - }, - "dependencies": { - "@vortexjs/core": "workspace:*", - "@vortexjs/dom": "workspace:*", - "@vortexjs/common": "workspace:*" - }, - "peerDependencies": { - "typescript": "catalog:" - }, - "scripts": { - "build": "tsdown ./src/index.ts --format esm --dts --out-dir dist" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - } - }, - "version": "0.0.0" + "name": "@vortexjs/move", + "type": "module", + "license": "MIT-0", + "repository": { + "url": "https://github.com/rectangle-run/vortex" + }, + "devDependencies": { + "@types/bun": "catalog:", + "tsdown": "catalog:" + }, + "dependencies": { + "@vortexjs/core": "workspace:*", + "@vortexjs/dom": "workspace:*", + "@vortexjs/common": "workspace:*" + }, + "peerDependencies": { + "typescript": "catalog:" + }, + "scripts": { + "build": "tsdown ./src/index.ts --format esm --dts --out-dir dist", + "test": "bun test" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "version": "0.0.0" } diff --git a/packages/vortex-move/test/spring.test.ts b/packages/vortex-move/test/spring.test.ts new file mode 100644 index 0000000..b784cb4 --- /dev/null +++ b/packages/vortex-move/test/spring.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test"; +import { Spring } from "../src"; + +test("Springs scale properly", () => { + const springA = new Spring(0); + const springB = new Spring(0); + const springBScale = 0.5; + + springA.target = 100; + springB.target = 100 * springBScale; + + for (let i = 0; i < 1000; i++) { + springA.update(0.016); + springB.update(0.016); + + expect(springB.value).toBeCloseTo(springA.value * springBScale, 3); + } +});