├── .npmignore ├── example ├── src │ ├── main.jsx │ ├── components │ │ ├── Test.jsx │ │ └── RenderOnDemand.jsx │ └── index.css ├── .gitignore ├── index.html ├── README.md ├── vite.config.js ├── .eslintrc.cjs ├── package.json └── public │ └── vite.svg ├── .gitignore ├── index.html ├── vite.config.js ├── .eslintrc.cjs ├── src ├── index.js ├── utils.js ├── hooks.jsx └── Illustration.jsx ├── LICENSE ├── package.json └── readme.md /.npmignore: -------------------------------------------------------------------------------- 1 | examples/ -------------------------------------------------------------------------------- /example/src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./components/RenderOnDemand"; 4 | // import App from "./components/Test"; 5 | import "./index.css"; 6 | 7 | ReactDOM.createRoot(document.getElementById("root")).render( 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /example/.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 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # React + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | -------------------------------------------------------------------------------- /example/vite.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { defineConfig } from "vite"; 3 | import react from "@vitejs/plugin-react"; 4 | import path from "path"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig(({ mode }) => { 8 | let alias = {}; 9 | 10 | if (mode === "development") { 11 | alias = { 12 | "react-zdog": path.resolve(__dirname, "../src/index.js"), 13 | }; 14 | } 15 | 16 | return { 17 | resolve: { 18 | alias: alias, 19 | }, 20 | plugins: [react()], 21 | }; 22 | }); 23 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import path from "node:path"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | build: { 9 | lib: { 10 | entry: path.resolve(__dirname, "src/index.js"), 11 | name: "react-zdog", 12 | formats: ["es"], 13 | fileName: (format) => `react-zdog.${format}.js`, 14 | }, 15 | rollupOptions: { 16 | external: ["react", "react-dom", "zdog"], 17 | }, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:react/recommended', 7 | 'plugin:react/jsx-runtime', 8 | 'plugin:react-hooks/recommended', 9 | ], 10 | ignorePatterns: ['dist', '.eslintrc.cjs'], 11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 12 | settings: { react: { version: '18.2' } }, 13 | plugins: ['react-refresh'], 14 | rules: { 15 | 'react-refresh/only-export-components': [ 16 | 'warn', 17 | { allowConstantExport: true }, 18 | ], 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /example/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:react/recommended', 7 | 'plugin:react/jsx-runtime', 8 | 'plugin:react-hooks/recommended', 9 | ], 10 | ignorePatterns: ['dist', '.eslintrc.cjs'], 11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 12 | settings: { react: { version: '18.2' } }, 13 | plugins: ['react-refresh'], 14 | rules: { 15 | 'react-refresh/only-export-components': [ 16 | 'warn', 17 | { allowConstantExport: true }, 18 | ], 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0", 15 | "react-zdog": "^1.2.1", 16 | "zdog": "^1.1.3" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^18.2.15", 20 | "@types/react-dom": "^18.2.7", 21 | "@vitejs/plugin-react": "^4.0.3", 22 | "eslint": "^8.45.0", 23 | "eslint-plugin-react": "^7.32.2", 24 | "eslint-plugin-react-hooks": "^4.6.0", 25 | "eslint-plugin-react-refresh": "^0.4.3", 26 | "vite": "^4.4.5" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Zdog from 'zdog' 2 | import { useRender, useInvalidate, useZdog } from './hooks' 3 | import { createZdog } from './utils' 4 | import { Illustration } from './Illustration' 5 | 6 | const Anchor = createZdog(Zdog.Anchor) 7 | const Shape = createZdog(Zdog.Shape) 8 | const Group = createZdog(Zdog.Group) 9 | const Rect = createZdog(Zdog.Rect) 10 | const RoundedRect = createZdog(Zdog.RoundedRect) 11 | const Ellipse = createZdog(Zdog.Ellipse) 12 | const Polygon = createZdog(Zdog.Polygon) 13 | const Hemisphere = createZdog(Zdog.Hemisphere) 14 | const Cylinder = createZdog(Zdog.Cylinder) 15 | const Cone = createZdog(Zdog.Cone) 16 | const Box = createZdog(Zdog.Box) 17 | 18 | export { 19 | Illustration, 20 | useRender, 21 | useZdog, 22 | useInvalidate, 23 | Anchor, 24 | Shape, 25 | Group, 26 | Rect, 27 | RoundedRect, 28 | Ellipse, 29 | Polygon, 30 | Hemisphere, 31 | Cylinder, 32 | Cone, 33 | Box, 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Paul Henschel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-zdog", 3 | "version": "1.2.2", 4 | "description": "React-fiber renderer for zdog", 5 | "type": "module", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/drcmda/react-zdog.git" 9 | }, 10 | "keywords": [ 11 | "react", 12 | "renderer", 13 | "fiber", 14 | "zdog" 15 | ], 16 | "author": "Paul Henschel", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/drcmda/react-zdog/issues" 20 | }, 21 | "homepage": "https://github.com/drcmda/react-zdog#readme", 22 | "scripts": { 23 | "dev": "vite", 24 | "build": "vite build", 25 | "publish:patch": "npm version patch && npm run build && npm publish", 26 | "publish:minor": "npm version minor && npm run build && npm publish", 27 | "publish:major": "npm version major && npm run build && npm publish", 28 | "preview": "vite preview" 29 | }, 30 | "dependencies": { 31 | "react": "^18.2.0", 32 | "react-dom": "^18.2.0", 33 | "resize-observer-polyfill": "^1.5.1" 34 | }, 35 | "devDependencies": { 36 | "@types/react": "^18.2.15", 37 | "@types/react-dom": "^18.2.7", 38 | "@vitejs/plugin-react": "^4.0.3", 39 | "eslint": "^8.45.0", 40 | "eslint-plugin-react": "^7.32.2", 41 | "eslint-plugin-react-hooks": "^4.6.0", 42 | "eslint-plugin-react-refresh": "^0.4.3", 43 | "vite": "^4.4.5" 44 | }, 45 | "files": [ 46 | "dist" 47 | ], 48 | "module": "./dist/react-zdog.es.js", 49 | "exports": { 50 | ".": { 51 | "import": "./dist/react-zdog.es.js" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /example/src/components/Test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import { TAU } from "zdog"; 3 | import { useRef } from "react"; 4 | import { Illustration, Anchor, Shape } from "react-zdog"; 5 | 6 | const side = [ 7 | [-1, -1, 1], 8 | [-1, 0, 1], 9 | [-1, 1, 1], 10 | [0, -1, 1], 11 | [0, 1, 1], 12 | [1, -1, 1], 13 | [1, 0, 1], 14 | [1, 1, 1], 15 | ]; 16 | const middle = [ 17 | [1, 1, 0], 18 | [1, -1, 0], 19 | [-1, 1, 0], 20 | [-1, -1, 0], 21 | ]; 22 | 23 | function Dots({ stroke = 2.5, color = "lightblue", coords, ...props }) { 24 | return ( 25 | 26 | {coords.map(([x, y, z], index) => ( 27 | console.log(index, e, obj, ">>>>>>>>>>>>>>>>")} 30 | onPointerMove={() => console.log(index, "MOVE")} 31 | onPointerEnter={() => console.log(index, "Enter")} 32 | onPointerLeave={() => console.log(index, "Leave")} 33 | stroke={stroke} 34 | color={color} 35 | translate={{ x, y, z }} 36 | /> 37 | ))} 38 | 39 | ); 40 | } 41 | 42 | function Box() { 43 | let ref = useRef(undefined); 44 | 45 | return ( 46 | 47 | 48 | 49 | 50 | 51 | ); 52 | } 53 | 54 | export default function App() { 55 | return ( 56 | 63 | 64 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import Zdog from 'zdog' 2 | import React from 'react' 3 | import { useZdogPrimitive } from './hooks' 4 | 5 | export function applyProps(instance, newProps) { 6 | Zdog.extend(instance, newProps) 7 | } 8 | 9 | export const createZdog = primitive => 10 | React.forwardRef(({ children, ...rest }, ref) => useZdogPrimitive(primitive, children, rest, ref)[0]) 11 | 12 | export function generateRandomHexColor() { 13 | const randomInt = Math.floor(Math.random() * 16777216) 14 | const hexColor = randomInt.toString(16).toUpperCase() 15 | const color = '#' + hexColor.padStart(6, '0') 16 | if (color === '#000000') { 17 | return generateRandomHexColor() 18 | } else { 19 | return '#' + hexColor.padStart(6, '0') 20 | } 21 | } 22 | 23 | const componentToHex = c => { 24 | let hex = c.toString(16) 25 | return hex.length == 1 ? '0' + hex : hex 26 | } 27 | 28 | export const rgbToHex = (r, g, b) => { 29 | return '#' + componentToHex(r) + componentToHex(g) + componentToHex(b) 30 | } 31 | 32 | export function createProxy(target, handleChange, parentProp) { 33 | return new Proxy(target, { 34 | set(obj, prop, value) { 35 | if (typeof value === 'object' && value !== null) { 36 | value = createProxy(value, handleChange) 37 | } 38 | handleChange(obj, prop, value, parentProp) 39 | obj[prop] = value 40 | return true 41 | }, 42 | get(obj, prop) { 43 | if (typeof obj[prop] === 'object' && obj[prop] !== null) { 44 | return createProxy(obj[prop], handleChange, prop) 45 | } 46 | return obj[prop] 47 | }, 48 | }) 49 | } 50 | 51 | export const getMousePos = (canvas, evt, canvas_ghost) => { 52 | const rect = canvas.getBoundingClientRect() 53 | return { 54 | x: ((evt.clientX - rect.left) / (rect.right - rect.left)) * canvas_ghost.width, 55 | y: ((evt.clientY - rect.top) / (rect.bottom - rect.top)) * canvas_ghost.height, 56 | } 57 | } 58 | 59 | export const getPixel = ({ x, y, canvasContext }) => { 60 | let imageData = canvasContext.getImageData(x, y, 1, 1) 61 | let data = imageData.data 62 | return rgbToHex(data[0], data[1], data[2]) 63 | } 64 | -------------------------------------------------------------------------------- /example/src/components/RenderOnDemand.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import { TAU } from "zdog"; 3 | import { useEffect, useRef, useState } from "react"; 4 | import { 5 | Illustration, 6 | Anchor, 7 | Shape, 8 | useInvalidate, 9 | useZdog, 10 | } from "react-zdog"; 11 | 12 | const side = [ 13 | [-1, -1, 1], 14 | [-1, 0, 1], 15 | [-1, 1, 1], 16 | [0, -1, 1], 17 | [0, 1, 1], 18 | [1, -1, 1], 19 | [1, 0, 1], 20 | [1, 1, 1], 21 | ]; 22 | const middle = [ 23 | [1, 1, 0], 24 | [1, -1, 0], 25 | [-1, 1, 0], 26 | [-1, -1, 0], 27 | ]; 28 | 29 | function Dots({ stroke = 2.5, color = "lightblue", coords, ...props }) { 30 | return ( 31 | 32 | {coords.map(([x, y, z], index) => ( 33 | 39 | ))} 40 | 41 | ); 42 | } 43 | 44 | function Box() { 45 | let ref = useRef(undefined); 46 | 47 | const [color, setColor] = useState("lightblue"); 48 | 49 | useEffect(() => { 50 | const t = setInterval(() => { 51 | setColor((c) => (c === "lightblue" ? "red" : "lightblue")); 52 | }, 2000); 53 | 54 | return () => clearTimeout(t); 55 | }, []); 56 | 57 | return ( 58 | 59 | 65 | 66 | 67 | 68 | ); 69 | } 70 | 71 | const DragControl = () => { 72 | const invalidate = useInvalidate(); 73 | 74 | const state = useZdog(); 75 | 76 | useEffect(() => { 77 | if (state.illu) { 78 | state.illu.onDragMove = function () { 79 | invalidate(); 80 | }; 81 | } 82 | }, [state, invalidate]); 83 | 84 | return <>; 85 | }; 86 | 87 | export default function App() { 88 | return ( 89 | 95 | 96 | 97 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, 6 | body, 7 | #root { 8 | width: 100%; 9 | height: 100%; 10 | margin: 0; 11 | padding: 0; 12 | background-color: #272727; 13 | -webkit-touch-callout: none; 14 | -webkit-user-select: none; 15 | -khtml-user-select: none; 16 | -moz-user-select: none; 17 | -ms-user-select: none; 18 | user-select: none; 19 | overflow: hidden; 20 | } 21 | 22 | #root { 23 | overflow: auto; 24 | } 25 | 26 | body { 27 | position: fixed; 28 | overflow: hidden; 29 | overscroll-behavior-y: none; 30 | font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, 31 | helvetica neue, helvetica, ubuntu, roboto, noto, segoe ui, arial, sans-serif; 32 | color: black; 33 | -webkit-font-smoothing: antialiased; 34 | } 35 | 36 | .main { 37 | position: relative; 38 | width: 100%; 39 | height: 100%; 40 | color: #fff8de; 41 | overflow: hidden; 42 | } 43 | 44 | span.header { 45 | font-family: "Josefin Sans", sans-serif; 46 | font-weight: 700; 47 | position: absolute; 48 | display: inline-block; 49 | width: 500px; 50 | transform: translate3d(0, -50%, 0); 51 | font-size: 9em; 52 | line-height: 0.9em; 53 | pointer-events: none; 54 | top: 350px; 55 | left: 50px; 56 | } 57 | 58 | span.header-left { 59 | font-family: "Josefin Sans", sans-serif; 60 | font-weight: 700; 61 | position: absolute; 62 | display: inline-block; 63 | transform: translate3d(0, -50%, 0); 64 | line-height: 1em; 65 | top: 200px; 66 | left: 60px; 67 | font-size: 4em; 68 | width: 200px; 69 | } 70 | 71 | div.header-major { 72 | font-family: "Josefin Sans", sans-serif; 73 | font-weight: 700; 74 | position: absolute; 75 | top: 0; 76 | left: 0; 77 | width: 100%; 78 | height: 100%; 79 | display: flex; 80 | align-items: center; 81 | justify-content: center; 82 | } 83 | 84 | div.header-major > span { 85 | font-size: 15em; 86 | } 87 | 88 | @media only screen and (max-width: 600px) { 89 | span.header { 90 | top: 200px; 91 | left: 60px; 92 | font-size: 4em; 93 | width: 200px; 94 | } 95 | .bottom-left { 96 | display: none; 97 | } 98 | } 99 | 100 | a, 101 | .main > span { 102 | font-family: "Josefin Sans", sans-serif; 103 | font-weight: 400; 104 | font-size: 18px; 105 | color: inherit; 106 | position: absolute; 107 | display: inline; 108 | text-decoration: none; 109 | z-index: 1; 110 | } 111 | 112 | .main > span { 113 | z-index: 0; 114 | } 115 | 116 | .main > span > a { 117 | position: unset; 118 | text-transform: capitalize; 119 | } 120 | 121 | .top-left { 122 | top: 60px; 123 | left: 60px; 124 | } 125 | 126 | .top-right { 127 | top: 60px; 128 | right: 60px; 129 | } 130 | 131 | .bottom-left { 132 | bottom: 60px; 133 | left: 60px; 134 | } 135 | 136 | .bottom-right { 137 | bottom: 60px; 138 | right: 60px; 139 | } 140 | 141 | canvas { 142 | width: 100%; 143 | height: 100%; 144 | position: absolute; 145 | top: 0; 146 | overflow: hidden; 147 | } 148 | 149 | .grid { 150 | display: flex; 151 | flex-wrap: wrap; 152 | } 153 | 154 | .grid .item { 155 | position: relative; 156 | width: 50vw; 157 | height: 50vw; 158 | background: #eee; 159 | } 160 | 161 | @media only screen and (max-width: 480px) { 162 | .grid .item { 163 | width: 100vw; 164 | height: 100vw; 165 | } 166 | } 167 | 168 | .scroll-container { 169 | position: absolute; 170 | overflow: auto; 171 | top: 0px; 172 | width: 100%; 173 | height: 100vh; 174 | font-size: 20em; 175 | font-weight: 800; 176 | line-height: 0.9em; 177 | } 178 | 179 | span.middle { 180 | font-family: "Josefin Sans", sans-serif; 181 | font-weight: 700; 182 | position: absolute; 183 | display: inline-block; 184 | font-size: 60vh; 185 | line-height: 0.9em; 186 | pointer-events: none; 187 | top: 50%; 188 | left: 50%; 189 | transform: translate3d(-50%, -37%, 0); 190 | text-transform: uppercase; 191 | letter-spacing: -40px; 192 | } 193 | -------------------------------------------------------------------------------- /src/hooks.jsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useContext, 3 | useRef, 4 | useEffect, 5 | useLayoutEffect, 6 | useState, 7 | useImperativeHandle, 8 | useCallback, 9 | useMemo, 10 | } from 'react' 11 | import ResizeObserver from 'resize-observer-polyfill' 12 | import { applyProps, createProxy, generateRandomHexColor } from './utils' 13 | 14 | export const stateContext = React.createContext() 15 | export const parentContext = React.createContext() 16 | const ghostParentContext = React.createContext() 17 | 18 | export function useMeasure() { 19 | const ref = useRef() 20 | const [bounds, set] = useState({ left: 0, top: 0, width: 0, height: 0 }) 21 | const [ro] = useState(() => new ResizeObserver(([entry]) => set(entry.contentRect))) 22 | useEffect(() => { 23 | if (ref.current) ro.observe(ref.current) 24 | return () => ro.disconnect() 25 | }, [ref.current]) 26 | return [{ ref }, bounds] 27 | } 28 | 29 | export function useRender(fn, deps = []) { 30 | const state = useContext(stateContext) 31 | useEffect(() => { 32 | // Subscribe to the render-loop 33 | const unsubscribe = state.current.subscribe(fn) 34 | // Call subscription off on unmount 35 | return () => unsubscribe() 36 | }, deps) 37 | } 38 | 39 | export function useZdog() { 40 | const state = useContext(stateContext) 41 | return state.current 42 | } 43 | 44 | export function useZdogPrimitive(primitive, children, props, ref) { 45 | const state = useContext(stateContext) 46 | const parent = useContext(parentContext) 47 | 48 | const ghostParent = useContext(ghostParentContext) 49 | 50 | const colorId = useMemo(() => generateRandomHexColor(), []) 51 | 52 | const hiddenNodeProps = useMemo(() => { 53 | return { 54 | stroke: false, 55 | ...props, 56 | color: colorId, 57 | leftFace: colorId, 58 | rightFace: colorId, 59 | topFace: colorId, 60 | bottomFace: colorId, 61 | } 62 | }, [colorId, props]) 63 | 64 | const [node] = useState(() => new primitive(props)) 65 | const [ghost_node] = useState(() => new primitive(hiddenNodeProps)) 66 | 67 | const syncGhostNode = (obj, prop, value, parentProp) => { 68 | if (parentProp) { 69 | ghost_node[parentProp][prop] = value 70 | } else { 71 | ghost_node[prop] = value 72 | } 73 | 74 | state.current.illu.updateRenderGraph() 75 | } 76 | 77 | const [proxyNode] = useState(() => createProxy(node, syncGhostNode)) 78 | 79 | useImperativeHandle(ref, () => proxyNode) 80 | 81 | useLayoutEffect(() => { 82 | applyProps(node, props) 83 | if (parent) { 84 | state.current.illu.updateRenderGraph() 85 | } 86 | }, [props]) 87 | 88 | useLayoutEffect(() => { 89 | applyProps(ghost_node, hiddenNodeProps) 90 | }, [hiddenNodeProps]) 91 | 92 | useLayoutEffect(() => { 93 | if (!parent) return 94 | 95 | parent.addChild(node) 96 | state.current.illu.updateGraph() 97 | 98 | return () => { 99 | parent.removeChild(node) 100 | parent.updateFlatGraph() 101 | state.current.illu.updateGraph() 102 | } 103 | }, [parent]) 104 | 105 | useEffect(() => { 106 | if (!parent) return 107 | 108 | state.current.itemMap[colorId] = node 109 | if (props.onClick) { 110 | state.current.clickEventMap[colorId] = props.onClick 111 | } 112 | if (props.onPointerMove) { 113 | state.current.pointerMoveEventMap[colorId] = props.onPointerMove 114 | } 115 | if (props.onPointerEnter) { 116 | state.current.pointerEnterEventMap[colorId] = props.onPointerEnter 117 | } 118 | if (props.onPointerLeave) { 119 | state.current.pointerLeaveEventMap[colorId] = props.onPointerLeave 120 | } 121 | 122 | return () => { 123 | delete state.current.itemMap[colorId] 124 | delete state.current.clickEventMap[colorId] 125 | delete state.current.pointerMoveEventMap[colorId] 126 | delete state.current.pointerEnterEventMap[colorId] 127 | delete state.current.pointerLeaveEventMap[colorId] 128 | } 129 | }, [props]) 130 | 131 | useLayoutEffect(() => { 132 | if (!ghostParent) return 133 | 134 | ghostParent.addChild(ghost_node) 135 | state.current.illu_ghost.updateGraph() 136 | 137 | return () => { 138 | ghostParent.removeChild(ghost_node) 139 | ghostParent.updateFlatGraph() 140 | state.current.illu_ghost.updateGraph() 141 | } 142 | }, [ghostParent]) 143 | 144 | return [ 145 | 146 | {children} 147 | , 148 | node, 149 | ghost_node, 150 | ] 151 | } 152 | 153 | export function useInvalidate() { 154 | const state = useZdog() 155 | 156 | const invalidate = useCallback(() => state.illu.updateRenderGraph(), [state]) 157 | 158 | return invalidate 159 | } 160 | -------------------------------------------------------------------------------- /src/Illustration.jsx: -------------------------------------------------------------------------------- 1 | import Zdog from 'zdog' 2 | import React, { useState, useRef, useEffect, useLayoutEffect } from 'react' 3 | import { useMeasure, useZdogPrimitive, stateContext } from './hooks' 4 | import { applyProps, getMousePos, getPixel } from './utils' 5 | 6 | export const Illustration = React.memo( 7 | ({ 8 | children, 9 | style, 10 | resize, 11 | element: Element = 'svg', 12 | frameloop = 'always', 13 | dragRotate, 14 | onDragMove = () => {}, 15 | onDragStart = () => {}, 16 | onDragEnd = () => {}, 17 | pointerEvents = false, 18 | ...rest 19 | }) => { 20 | const canvas = useRef() 21 | 22 | //ref to secondary canvas and 2d context 23 | const canvas_ghost = useRef() 24 | 25 | const [ghostCanvasContext, setGhostCanvasContext] = useState(null) 26 | 27 | useEffect(() => { 28 | setGhostCanvasContext(canvas_ghost.current.getContext('2d', { willReadFrequently: true })) 29 | }, []) 30 | 31 | const [bind, size] = useMeasure() 32 | const [result, scene, ghostScene] = useZdogPrimitive(Zdog.Anchor, children) 33 | 34 | const state = useRef({ 35 | scene, 36 | illu: undefined, 37 | size: {}, 38 | subscribers: [], 39 | subscribe: fn => { 40 | state.current.subscribers.push(fn) 41 | return () => (state.current.subscribers = state.current.subscribers.filter(s => s !== fn)) 42 | }, 43 | illu_ghost: undefined, 44 | itemMap: {}, 45 | clickEventMap: {}, 46 | pointerMoveEventMap: {}, 47 | pointerEnterEventMap: {}, 48 | pointerLeaveEventMap: {}, 49 | pointerEvents, 50 | }) 51 | 52 | useEffect(() => { 53 | state.current.size = size 54 | if (state.current.illu) { 55 | state.current.illu.setSize(size.width, size.height) 56 | state.current.illu_ghost.setSize(size.width, size.height) 57 | if (frameloop === 'demand') { 58 | state.current.illu.updateRenderGraph() 59 | state.current.illu_ghost.updateRenderGraph() 60 | } 61 | } 62 | }, [size]) 63 | 64 | useEffect(() => { 65 | state.current.illu = new Zdog.Illustration({ 66 | element: canvas.current, 67 | dragRotate, 68 | onDragMove: () => { 69 | state.current.illu_ghost.rotate = { 70 | x: state.current.illu.rotate.x, 71 | y: state.current.illu.rotate.y, 72 | z: state.current.illu.rotate.z, 73 | } 74 | onDragMove() 75 | }, 76 | onDragStart: onDragStart, 77 | onDragEnd: onDragEnd, 78 | ...rest, 79 | }) 80 | state.current.illu.addChild(scene) 81 | state.current.illu.updateGraph() 82 | 83 | state.current.illu_ghost = new Zdog.Illustration({ 84 | element: canvas_ghost.current, 85 | ...rest, 86 | }) 87 | state.current.illu_ghost.addChild(ghostScene) 88 | state.current.illu_ghost.updateGraph() 89 | 90 | let frame 91 | let active = true 92 | function render(t) { 93 | const { size, subscribers } = state.current 94 | if (size.width && size.height) { 95 | // Run local effects 96 | subscribers.forEach(fn => fn(t)) 97 | // Render scene 98 | if (frameloop !== 'demand') { 99 | state.current.illu.updateRenderGraph() 100 | } 101 | } 102 | if (active && frameloop !== 'demand') frame = requestAnimationFrame(render) 103 | } 104 | 105 | // Start render loop 106 | render() 107 | 108 | return () => { 109 | // Take no chances, the loop has got to stop if the component unmounts 110 | active = false 111 | cancelAnimationFrame(frame) 112 | } 113 | }, [frameloop]) 114 | 115 | // Takes care of updating the main illustration 116 | useLayoutEffect(() => { 117 | state.current.illu && applyProps(state.current.illu, rest) 118 | state.current.illu_ghost && applyProps(state.current.illu_ghost, rest) 119 | }, [rest]) 120 | 121 | const click = e => { 122 | if (!pointerEvents) return 123 | 124 | state.current.illu_ghost && state.current.illu_ghost.updateRenderGraph() 125 | const coords = getMousePos(canvas.current, e, canvas_ghost.current) 126 | const pixel = getPixel({ ...coords, canvasContext: ghostCanvasContext }) 127 | const colorId = pixel.toUpperCase() 128 | const clickEvent = state.current.clickEventMap[colorId] 129 | clickEvent && clickEvent(e, state.current.itemMap[colorId]) 130 | } 131 | 132 | const prevColorId = useRef(null) 133 | const pointerOnObj = useRef(null) 134 | 135 | const setPointerOnObj = newState => { 136 | pointerOnObj.current = newState 137 | } 138 | 139 | const pointerMove = e => { 140 | if (!pointerEvents) return 141 | 142 | state.current.illu_ghost && state.current.illu_ghost.updateRenderGraph() 143 | const coords = getMousePos(canvas.current, e, canvas_ghost.current) 144 | const pixel = getPixel({ ...coords, canvasContext: ghostCanvasContext }) 145 | const colorId = pixel.toUpperCase() 146 | 147 | if (colorId !== '#000000' && prevColorId.current !== colorId && pointerOnObj.current !== colorId) { 148 | const pointerEnterEvent = state.current.pointerEnterEventMap[colorId] 149 | pointerEnterEvent && pointerEnterEvent(e, state.current.itemMap[colorId]) 150 | setPointerOnObj(prevColorId.current) 151 | } 152 | 153 | if ( 154 | prevColorId.current && 155 | prevColorId.current !== '#000000' && 156 | prevColorId.current !== colorId && 157 | pointerOnObj.current 158 | ) { 159 | const pointerLeaveEvent = state.current.pointerLeaveEventMap[prevColorId.current] 160 | pointerLeaveEvent && pointerLeaveEvent(e, state.current.itemMap[prevColorId.current]) 161 | } 162 | 163 | const pointerMoveEvent = state.current.pointerMoveEventMap[colorId] 164 | pointerMoveEvent && pointerMoveEvent(e, state.current.itemMap[colorId]) 165 | 166 | prevColorId.current = colorId 167 | } 168 | 169 | return ( 170 | <> 171 |
182 | 190 | {state.current.illu && } 191 |
192 | 206 | 207 | ) 208 | } 209 | ) 210 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | npm install zdog react-zdog 6 | # or 7 | yarn add zdog react-zdog 8 | 9 | ![npm](https://img.shields.io/npm/v/react-zdog.svg?style=flat-square) ![npm](https://img.shields.io/npm/dt/react-zdog.svg?style=flat-square) 10 | 11 | react-zdog is a declarative abstraction of [zdog](https://zzz.dog/), a cute pseudo 3d-engine. Doing zdog in React allows you to break up your scene graph into declarative, re-usable components with clean, reactive semantics. Try a live demo [here](https://codesandbox.io/s/nervous-feather-vk9uh). 12 | 13 | # How it looks like 14 | 15 | ```jsx 16 | import ReactDOM from "react-dom"; 17 | import React from "react"; 18 | import { Illustration, Shape } from "react-zdog"; 19 | 20 | ReactDOM.render( 21 | 22 | 23 | , 24 | document.getElementById("root") 25 | ); 26 | ``` 27 | 28 | # Illustration 29 | 30 | The `Illustration` object is your portal into zdog. It forwards unreserved properties to the internal Zdog.Illustration instance. The component auto adjusts to re-size changes and fills out the wrapping relative/absolute parent. 31 | 32 | ```jsx 33 | // Can be either 'svg' or 'canvas' 34 | ``` 35 | 36 | - `element`: Sets the graphics rendering DOM Element. Can be either 'svg' or 'canvas'. Default is "svg" 37 | - `frameloop`: Determins the render loop behavior, Can be either 'always' or 'demand'. default is 'always'. 38 | - `pointerEvents`: enables pointer events on zdog elements if set to true. Default is False. 39 | - `style`: styles for main renderer dom elemeent container. 40 | - `onDragStart`: callback on illustration's on drag start event listener 41 | - `onDragMove`: callback on illustration's on drag move event listener 42 | - `onDragEnd`: callback on illustration's on drag end event listener 43 | 44 | And all the other props you will pass will be attached to illustration object. So any other properties or methods that you wanna set on illustration can be passed as prop as it is. 45 | 46 | # Hooks 47 | 48 | All hooks can only be used _inside_ the Illustration element because they rely on context updates! 49 | 50 | #### useRender(callback, dependencies=[]) 51 | 52 | If you're running effects that need to get updated every frame, useRender gives you access to the render-loop. 53 | 54 | ```jsx 55 | import { useRender } from "react-zdog"; 56 | 57 | function Spin({ children }) { 58 | const ref = useRef(undefined); 59 | useRender((t) => (ref.current.rotate.y += 0.01)); 60 | return {children}; 61 | } 62 | ``` 63 | 64 | #### useZdog() 65 | 66 | Gives you access to the underlying state-model. 67 | 68 | ```jsx 69 | import { useZdog } from 'react-zdog' 70 | 71 | function MyComponent() { 72 | const { 73 | illu, // The parent Zdog.Illustration object 74 | scene, // The Zdog.Anchor object that's being used as the default scene 75 | size, // Current canvas size 76 | } = useZdog() 77 | ``` 78 | 79 | ### useInvalidate() 80 | 81 | Gives you access to function that updates the one scene frame on each call. It is useful only if you're setting `frameloop` props on _Illustration_ component as `demand` 82 | 83 | ```jsx 84 | function MyComponent() { 85 | const invalidate = useInvalidate() 86 | const boxRef = useRef() 87 | const rotate = () => { 88 | boxRef.current.rotate.x += 0.03; 89 | boxRef.current.rotate.y += 0.03; //this will update underlying javascript object 90 | invalidate() //But you need to call invalidate to render the changes on screen 91 | } 92 | 93 | return ( 94 | 98 | )} 99 | ``` 100 | 101 | # Pointer Events 102 | 103 | React-zdog supports the Click, Pointer Move, Pointer Enter and Pointer Leave events on Zdog elemets. 104 | To use pointer events just enable the pointer events by setting `pointerEvents` prop to `true` on `Illustration` component. 105 | 106 | ```jsx 107 | 108 | ``` 109 | 110 | and use onClick, onPointerMove, onPointerEnter and OnPointerLeave on any zdog element. 111 | 112 | ```jsx 113 | const onClick = (e, ele) => { 114 | //runs when user clicks on box 115 | }; 116 | 117 | const onPointerMove = (e, ele) => { 118 | //runs when user moves pointer over box 119 | }; 120 | 121 | const onPointerEnter = (e, ele) => { 122 | //runs when user's pointer enters the box 123 | }; 124 | 125 | const onPointerLeave = (e, ele) => { 126 | //runs when user's pointer leaves the box 127 | }; 128 | 129 | return ( 130 | 136 | ); 137 | ``` 138 | 139 |
140 | Note: zdog dosen't support pointer events out of the box, it is react-zdog specific feature which is added recently and was tested, but if you find some issue with events (and with any other thing) please open a issue and let us know. 141 |
142 | 143 | # Examples 144 | 145 |
146 | Basic Example 147 | 148 | ```jsx 149 | import React, { useRef, useEffect } from 'react'; 150 | import { Illustration, useRender, useInvalidate, Box } from 'react-zdog'; 151 | 152 | // RotatingCube Component 153 | const RotatingCube = () => { 154 | const boxRef = useRef(); 155 | 156 | // Use the useRender hook to continuously update the rotation 157 | useRender(() => { 158 | if (boxRef.current) { 159 | boxRef.current.rotate.x += 0.03; 160 | boxRef.current.rotate.y += 0.03; 161 | } 162 | }); 163 | 164 | return ( 165 | 176 | ); 177 | 178 | }; 179 | 180 | // App Component 181 | const App = () => { 182 | return ( 183 | 184 | 185 | 186 | ); 187 | }; 188 | 189 | export default App; 190 | 191 | ```` 192 |
193 | 194 |
195 | Pointer Events Example 196 | 197 | ```jsx 198 | import React, { useRef, useState } from 'react'; 199 | import { Illustration, useRender, Box } from 'react-zdog'; 200 | 201 | // InteractiveCube Component 202 | const InteractiveCube = () => { 203 | const [isClicked, setIsClicked] = useState(false); 204 | 205 | const colorsBeforeClick = { 206 | main: "#E44", 207 | left: "#4E4", 208 | right: "#44E", 209 | top: "#EE4", 210 | bottom: "#4EE" 211 | }; 212 | 213 | const colorsAfterClick = { 214 | main: "#FF5733", 215 | left: "#33FF57", 216 | right: "#3357FF", 217 | top: "#FF33A1", 218 | bottom: "#A133FF" 219 | }; 220 | 221 | const currentColors = isClicked ? colorsAfterClick : colorsBeforeClick; 222 | 223 | const handleBoxClick = () => { 224 | setIsClicked(!isClicked); 225 | }; 226 | 227 | 228 | return ( 229 | 240 | ); 241 | }; 242 | 243 | // App Component 244 | const App = () => { 245 | return ( 246 | 247 | 248 | 249 | ); 250 | }; 251 | 252 | export default App; 253 | 254 | ```` 255 | 256 |
257 | 258 |
259 | On Demand rendering Example 260 | 261 | ```jsx 262 | import React, { useRef, useEffect } from "react"; 263 | import { Illustration, useInvalidate, Box } from "react-zdog"; 264 | 265 | // RotatingCube Component 266 | const RotatingCube = () => { 267 | const boxRef = useRef(); 268 | const invalidate = useInvalidate(); 269 | 270 | useEffect(() => { 271 | const animate = () => { 272 | if (boxRef.current) { 273 | boxRef.current.rotate.x += 0.03; 274 | boxRef.current.rotate.y += 0.03; 275 | invalidate(); // Manually trigger a render 276 | } 277 | }; 278 | 279 | const intervalId = setInterval(animate, 1000); // only renders the scene graph one a second instead of 60 times per second 280 | 281 | return () => intervalId && clearInterval(intervalId); 282 | }, [invalidate]); 283 | 284 | return ( 285 | 296 | ); 297 | }; 298 | 299 | // App Component 300 | const App = () => { 301 | return ( 302 | 303 | 304 | 305 | ); 306 | }; 307 | 308 | export default App; 309 | ``` 310 | 311 |
312 | 313 | # Roadmap 314 | 315 | - Create more Examples 316 | - add More events support 317 | 318 | # Contributing 319 | 320 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 321 | --------------------------------------------------------------------------------