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 diff --git a/packages/example/src/client.tsx b/packages/example/src/client.tsx index 41dd822..3786435 100644 --- a/packages/example/src/client.tsx +++ b/packages/example/src/client.tsx @@ -4,11 +4,12 @@ import { 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"); @@ -18,26 +19,69 @@ function TestingComponent() { return

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

; } -function SpringSliders() { +function LayoutTest() { 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`); + 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} + + + + ))} +
+ + ); +} + +function PopupTest() { + const isOpen = useState(false); + + return ( + <> + + {when(isOpen, () => ( +
+ This is a popup! +
+ ))} ); } @@ -67,7 +111,6 @@ function App() { on:click={() => { counter.set(getImmediateValue(counter) + 100); }} - use={({ ref: element }) => console.log("button element: ", element)} type="button" > Increment @@ -86,7 +129,8 @@ function App() {

))} - + + ); } 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..a80a54b --- /dev/null +++ b/packages/example/src/style.css @@ -0,0 +1,34 @@ +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/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/src/index.ts b/packages/vortex-move/src/index.ts index 272a95f..aa51e73 100644 --- a/packages/vortex-move/src/index.ts +++ b/packages/vortex-move/src/index.ts @@ -1,2 +1,4 @@ +export * from "./layout"; +export * from "./projection"; export * from "./scheduler"; export * from "./spring"; diff --git a/packages/vortex-move/src/layout.ts b/packages/vortex-move/src/layout.ts new file mode 100644 index 0000000..ef7da71 --- /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..cfee2c0 --- /dev/null +++ b/packages/vortex-move/src/projection.ts @@ -0,0 +1,57 @@ +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..bfbd61f 100644 --- a/packages/vortex-move/src/scheduler.ts +++ b/packages/vortex-move/src/scheduler.ts @@ -73,9 +73,11 @@ 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..5b7999b 100644 --- a/packages/vortex-move/src/spring.ts +++ b/packages/vortex-move/src/spring.ts @@ -6,19 +6,51 @@ import { } 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; + 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; 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); + } +});