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);
+ }
+});