diff --git a/layered-parallax/.gitignore b/layered-parallax/.gitignore new file mode 100644 index 0000000..4ce7d0c --- /dev/null +++ b/layered-parallax/.gitignore @@ -0,0 +1,16 @@ +.vscode/ +.idea/ +node_modules/ +build/ +.DS_Store +*.tgz +npm-debug.log* +yarn-debug.log* +yarn-error.log* +/.changelog +.npm/ + + + +## Vev +.vev/ diff --git a/layered-parallax/.prettierrc.json b/layered-parallax/.prettierrc.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/layered-parallax/.prettierrc.json @@ -0,0 +1 @@ +{} diff --git a/layered-parallax/README.md b/layered-parallax/README.md new file mode 100644 index 0000000..05c29b0 --- /dev/null +++ b/layered-parallax/README.md @@ -0,0 +1,48 @@ +# Getting started with Vev CLI + +> This project was bootstrapped with [Create Vev App](https://github.com/vev-design/create-vev-app). + + +### Initialize + +In your project directory run: +```bash +vev init +``` + +This will initialize a new components package in the Vev platform. + +### Run + +``` +vev start +``` +Now you can open the [Vev design editor](https://editor.vev.design/), your components will be available in all your projects as long as the CLI is running. + +### Build + +Open `./src/MyComponent.tsx` and start building your React components. + +* [Register Vev component](https://developer.vev.design/docs/cli/react/register-vev-component) +* [Vev props](https://developer.vev.design/docs/cli/react/vev-props) +* [Vev components]([/docs/cli/react/components](https://developer.vev.design/docs/cli/react/components)) +* [Vev hooks](https://developer.vev.design/docs/cli/react/hooks) +* [React documentation](https://reactjs.org/docs/getting-started.html) + +### Deploy + +Deploy your package: + +``` +vev deploy +``` + +You can choose to share your components with your account, workspace or team. Configuration is done in the [vev.json](https://developer.vev.design/docs/cli/configuration) file. + + +--- + + + +[Vev Developer Documentation](https://developer.vev.design/docs/cli/) + diff --git a/layered-parallax/package.json b/layered-parallax/package.json new file mode 100644 index 0000000..b36202c --- /dev/null +++ b/layered-parallax/package.json @@ -0,0 +1,21 @@ +{ + "name": "layered-parallax", + "version": "0.0.1", + "main": "index.js", + "scripts": { + "login": "vev login", + "init": "vev init", + "start": "vev start" + }, + "license": "MIT", + "devDependencies": { + "@types/react": "latest", + "prettier": "2.7.0", + "typescript-plugin-css-modules": "^5.0.1" + }, + "dependencies": { + "@vev/react": "latest", + "@vev/silke": "^1.0.15", + "react": "^18.2.0" + } +} diff --git a/layered-parallax/src/Layer.tsx b/layered-parallax/src/Layer.tsx new file mode 100644 index 0000000..78375ad --- /dev/null +++ b/layered-parallax/src/Layer.tsx @@ -0,0 +1,26 @@ +import { WidgetNode } from "@vev/react"; +import React, { useEffect, useLayoutEffect, useRef } from "react"; +import styles from "./LayeredParallax.module.css"; +type LayerProps = { + modelId: string; + depth: number; + disabled?: boolean; + selected?: boolean; +}; + +export function Layer({ modelId, depth, disabled, selected }: LayerProps) { + const ref = useRef(null); + useLayoutEffect(() => { + const el = ref.current; + el.style.setProperty("--depth", depth + ""); + }, [depth]); + + let cl = styles.layer; + if (disabled) cl += " " + styles.disabled; + + return ( +
+ +
+ ); +} diff --git a/layered-parallax/src/LayeredParallax.module.css b/layered-parallax/src/LayeredParallax.module.css new file mode 100644 index 0000000..21c4d86 --- /dev/null +++ b/layered-parallax/src/LayeredParallax.module.css @@ -0,0 +1,73 @@ +.wrapper { + --x: 0.5; + --y: 0.5; + --movement-x: 40; + --movement-y: 80; + --rotate: 0; + --perspective: 0; + + height: 100%; + overflow: inherit; + + &.rotate { + transform-style: preserve-3d; + perspective: 1000px; + + .layer { + transform-origin: center center 0; + translate: var(--offset-x) var(--offset-y) var(--offset-z); + transform: rotateY(calc((var(--x) - 0.5) * 90deg * var(--rotate))) + rotateX(calc((var(--x) - 0.5) * 90deg * var(--rotate))); + transform-style: preserve-3d; + } + } + + &:not(.rotate) { + .layer { + /** Need to use top and left and not translate since it's % based, and translate is based on the element's size */ + left: var(--offset-x); + top: var(--offset-y); + } + } +} + +.empty { + background-color: #eee; + display: flex; + height: 100%; + align-items: center; + justify-content: center; + color: #909090; + font-size: 14px; +} + +.layer { + --depth: 0; + --width: calc(100% + var(--movement-x) * var(--depth) * 1%); + --height: calc(100% + var(--movement-y) * var(--depth) * 1%); + --move-y: var(--movement-y) * var(--depth) * (var(--y) - 0.5) * 1%; + --move-x: var(--movement-x) * var(--depth) * (var(--x) - 0.5) * 1%; + --offset-x: calc(50% - var(--width) / 2 + var(--move-x)); + --offset-y: calc(50% - var(--height) / 2 + var(--move-y)); + --offset-z: calc((1 - var(--depth)) * -1px * var(--perspective)); + + position: absolute; + width: var(--width); + height: var(--height); + + will-change: top left transform; + + &.disabled { + opacity: 0.3; + filter: saturate(0%) contrast(30%); + } + + /* &::before { + position: absolute; + counter-reset: x calc(var(--x) * 1000) y calc(var(--y) * 1000); + content: "x:" counter(x) " y:" counter(y); + display: block; + background: black; + color: white; + } */ +} diff --git a/layered-parallax/src/LayeredParallax.tsx b/layered-parallax/src/LayeredParallax.tsx new file mode 100644 index 0000000..b928312 --- /dev/null +++ b/layered-parallax/src/LayeredParallax.tsx @@ -0,0 +1,293 @@ +import React, { useEffect, useLayoutEffect, useRef } from "react"; +import styles from "./LayeredParallax.module.css"; +import { + ArrayField, + SchemaFieldProps, + WidgetNode, + raf, + registerVevComponent, + useEditorState, +} from "@vev/react"; +import { Layer } from "./Layer"; +import { SilkeCssNumberField, SilkeBox } from "@vev/silke"; + +type AxisEffect = "none" | "mouse" | "scroll" | "both"; +const MAGIC_NUMBER = 30; + +type Props = { + layerDepth: number[]; + damping?: number; + movementX?: number; + movementY?: number; + effectX: AxisEffect; + effectY: AxisEffect; + rotate?: number; + perspective?: number; + children?: string[]; +}; +const setVar = ( + el: HTMLDivElement, + prop: "x" | "y" | "movement-x" | "movement-y" | "rotate" | "perspective", + value: number +) => { + el.style.setProperty("--" + prop, value + ""); +}; + +const LayeredParallax = ({ + effectX, + effectY, + movementX, + movementY, + damping = 0.1, + layerDepth, + rotate, + perspective, + children, +}: Props) => { + const { disabled, selected, activeContentFrame, activeContentChild } = + useEditorState(); + + const ref = useRef(null); + + const stepSize = 0.75 / children.length; + useEffect(() => { + setVar(ref.current, "movement-x", movementX || 40); + setVar(ref.current, "movement-y", movementY || 80); + setVar(ref.current, "rotate", rotate || 0); + setVar(ref.current, "perspective", perspective * 5000 || 0); + }, [movementX, movementY, perspective, rotate]); + + useLayoutEffect(() => { + const el = ref.current; + + if (disabled) { + setVar(el, "x", 0.5); + setVar(el, "y", 0.5); + return; + } + + const desiredMouse = [0.5, 0.5]; + const desiredScroll = [0.5, 1]; + const current = [0.5, 0.5]; + const axisName: ["x", "y"] = ["x", "y"]; + let elementInfo: [offsetTop: number, height: number]; + let lastFrame = 0; + + const handleMouseMove = (e: MouseEvent) => { + const { clientX, clientY } = e; + const { innerWidth, innerHeight } = window; + desiredMouse[0] = 1 - clientX / innerWidth; + desiredMouse[1] = 1 - clientY / innerHeight; + }; + + const getOffsetTop = (el: HTMLElement) => { + let offsetTop = 0; + do { + if (!isNaN(el.offsetTop)) { + offsetTop += el.offsetTop; + } + } while ((el = el.offsetParent as HTMLElement)); + return offsetTop; + }; + + const getProgressAcrossScreen = (el: HTMLElement) => { + if (!elementInfo) elementInfo = [getOffsetTop(el), el.offsetHeight]; + const [offsetTop, height] = elementInfo; + + const duration = Math.min(window.innerHeight, offsetTop) + height; + const top = offsetTop - window.scrollY; + const progress = Math.min(1, Math.max(0, 1 - (top + height) / duration)); + + return progress; + }; + + const handleScroll = (e) => { + desiredScroll[1] = 1 - getProgressAcrossScreen(el); + }; + + const handleMotion = (e: DeviceOrientationEvent) => { + const { beta, gamma } = e; + if (beta !== null && gamma !== null) { + const range = 90; + const yPercent = 1 - (beta + range / 2) / range; + const xPercent = 1 - (gamma + range / 2) / range; + // Detect Orientation Change + const portrait = window.innerHeight > window.innerWidth; + if (portrait) { + } + + desiredMouse[0] = Math.max(0, Math.min(1, xPercent)); + desiredMouse[1] = Math.max(0, Math.min(1, yPercent)); + } + }; + + if (effectX === "mouse" || effectY === "mouse" || effectY === "both") { + window.addEventListener("mousemove", handleMouseMove, { passive: true }); + window.addEventListener("deviceorientation", handleMotion); + } + + if (effectY === "scroll" || effectY === "both") { + window.addEventListener("scroll", handleScroll, { passive: true }); + setVar(el, "y", getProgressAcrossScreen(el)); + } + + const unSub = raf((time) => { + if (time - lastFrame > 500) elementInfo = undefined; + lastFrame = time; + + for (let i = 0; i < 2; i++) { + let desired = current[i]; + + if (i === 1 && effectY === "scroll") { + desired = desiredScroll[i]; + } else if (i === 1 && effectY === "both") { + desired = desiredScroll[i] * 0.8 + desiredMouse[i] * 0.2; + } else { + desired = desiredMouse[i]; + } + const diff = desired - current[i]; + if (diff) { + current[i] += diff * damping; + if (Math.abs(current[i] - desired) < 0.01) current[i] = desired; + setVar(el, axisName[i], Math.round(current[i] * 1000) / 1000); + } + } + }, true); + return () => { + unSub(); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("scroll", handleScroll); + window.removeEventListener("deviceorientation", handleMotion); + }; + }, [disabled, damping, effectX, effectY]); + + if (!children?.length) { + return
No layers
; + } + + let cl = styles.wrapper; + if (rotate) cl += " " + styles.rotate; + + return ( +
+ {children?.map((child: string, i: number) => ( + + ))} +
+ ); +}; + +function LayerDepthField(props: SchemaFieldProps) { + const children: string[] = props.context.value?.children || []; + const stepSize = 75 / children.length; + const depth = props.value || []; + + return ( + + {children.map((child: string, i: number) => ( + + + Layer {i + 1} movment + + { + const newDepth = [...depth]; + newDepth[i] = parseFloat(d) / 100; + props.onChange(newDepth); + }} + /> + + ))} + + ); +} + +registerVevComponent(LayeredParallax, { + name: "Parallax", + type: "both", + children: { + name: "Layer", + icon: "https://cdn.vev.design/private/dk3UctceTPWKJtA1g8n4EFqTvuo2/image/3GFUgAdpeb.svg", + }, + props: [ + { + name: "effectX", + title: "Effect X", + type: "select", + initialValue: "mouse", + options: { + display: "dropdown", + items: [ + { label: "None", value: "none" }, + { label: "Mouse/Gyroscope", value: "mouse" }, + ], + }, + }, + { + name: "effectY", + title: "Effect Y", + type: "select", + initialValue: "scroll", + options: { + display: "dropdown", + items: [ + { label: "None", value: "none" }, + { label: "Scroll/Mouse/Gyroscope", value: "both" }, + { label: "Mouse/Gyroscope", value: "mouse" }, + { label: "Scroll", value: "scroll" }, + ], + }, + }, + { name: "movementX", type: "number", initialValue: 40 }, + { name: "movementY", type: "number", initialValue: 80 }, + { + name: "rotate", + type: "number", + initialValue: 0, + title: "3D Rotation", + options: { display: "slider", min: 0, max: 1 }, + }, + { + name: "perspective", + title: "Depth effect", + hidden: (props) => !props.value?.rotate, + type: "number", + initialValue: 1000, + options: { display: "slider", min: 0, max: 1 }, + }, + + { name: "damping", type: "number", initialValue: 0.1 }, + + { name: "layerDepth", type: "array", component: LayerDepthField }, + ], + + editableCSS: [ + { + selector: styles.wrapper, + properties: ["background"], + }, + ], +}); + +export default LayeredParallax; diff --git a/layered-parallax/src/declarations.d.ts b/layered-parallax/src/declarations.d.ts new file mode 100644 index 0000000..fbb911b --- /dev/null +++ b/layered-parallax/src/declarations.d.ts @@ -0,0 +1,2 @@ +declare module "*.scss"; +declare module "*.css"; diff --git a/layered-parallax/tsconfig.json b/layered-parallax/tsconfig.json new file mode 100644 index 0000000..4974c39 --- /dev/null +++ b/layered-parallax/tsconfig.json @@ -0,0 +1,18 @@ +{ + "include": ["src/**/*"], + "exclude": ["node_modules", ".vev"], + "compilerOptions": { + "target": "es2020", + "lib": ["dom", "es2020"], + "jsx": "react", + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": false, + "plugins": [ + { + "name": "typescript-plugin-css-modules" + } + ] + } +} diff --git a/layered-parallax/vev.json b/layered-parallax/vev.json new file mode 100644 index 0000000..86ea6d8 --- /dev/null +++ b/layered-parallax/vev.json @@ -0,0 +1,3 @@ +{ + "key": "yO1raHNvticCOJeeHikk" +} \ No newline at end of file