├── readme.md
├── apps
└── electron
│ ├── src
│ ├── main
│ │ ├── index.ts
│ │ ├── env.ts
│ │ ├── ipc.ts
│ │ ├── menus.ts
│ │ └── classes
│ │ │ ├── tab.ts
│ │ │ ├── main.ts
│ │ │ ├── entrypoint.ts
│ │ │ └── window.ts
│ ├── @types
│ │ ├── globals.d.ts
│ │ └── env.d.ts
│ ├── renderer
│ │ ├── index.css
│ │ ├── components
│ │ │ ├── ui
│ │ │ │ ├── swatch.tsx
│ │ │ │ ├── sidebar-button.tsx
│ │ │ │ ├── icon-button.tsx
│ │ │ │ └── sidebar-panel.tsx
│ │ │ ├── fields
│ │ │ │ ├── info-field.tsx
│ │ │ │ ├── color-field.tsx
│ │ │ │ ├── margins-field.tsx
│ │ │ │ ├── boolean-field.tsx
│ │ │ │ ├── layer-field.tsx
│ │ │ │ ├── enum-field.tsx
│ │ │ │ ├── dimensions-field.tsx
│ │ │ │ ├── trait-field.tsx
│ │ │ │ └── number-field.tsx
│ │ │ ├── editor-sidebar.tsx
│ │ │ ├── window.tsx
│ │ │ ├── panels
│ │ │ │ ├── traits-panel.tsx
│ │ │ │ ├── seed-panel.tsx
│ │ │ │ ├── export-panel.tsx
│ │ │ │ ├── layout-panel.tsx
│ │ │ │ └── layers-panel.tsx
│ │ │ ├── editor-toolbar.tsx
│ │ │ ├── window-tabs.tsx
│ │ │ ├── editor.tsx
│ │ │ └── editor-console.tsx
│ │ ├── contexts
│ │ │ ├── sketch.tsx
│ │ │ ├── tab.tsx
│ │ │ ├── window.tsx
│ │ │ ├── entrypoint.tsx
│ │ │ └── store.tsx
│ │ ├── index.html
│ │ ├── utils.ts
│ │ └── index.tsx
│ ├── shared
│ │ ├── zoom.ts
│ │ ├── store
│ │ │ ├── renderer.ts
│ │ │ ├── preload.ts
│ │ │ ├── main.ts
│ │ │ └── base.ts
│ │ └── store-state.ts
│ └── preload
│ │ └── index.ts
│ ├── postcss.config.js
│ ├── resources
│ ├── icon.png
│ ├── installerIcon.ico
│ ├── uninstallerIcon.ico
│ └── entitlements.mac.plist
│ ├── tailwind.config.js
│ ├── test
│ └── error.ts
│ ├── electron.vite.config.ts
│ ├── scripts
│ └── notarize.js
│ ├── tsconfig.json
│ ├── electron-builder.config.js
│ └── package.json
├── packages
└── void
│ ├── src
│ ├── @types
│ │ ├── svgcanvas.d.ts
│ │ └── globals.d.ts
│ ├── index.ts
│ ├── config
│ │ ├── index.ts
│ │ └── methods.ts
│ ├── size
│ │ ├── methods.ts
│ │ └── index.ts
│ ├── sketch
│ │ ├── index.ts
│ │ └── methods.ts
│ ├── utils.ts
│ └── void.ts
│ ├── vite.config.ts
│ ├── rollup.config.js
│ ├── test
│ ├── size.test.ts
│ └── void.test.ts
│ ├── tsconfig.json
│ ├── package.json
│ └── readme.md
├── .prettierrc
├── docs
├── images
│ ├── banner-dark.png
│ ├── banner-light.png
│ ├── download-linux.png
│ ├── introduction.png
│ ├── recording.gif
│ ├── download-mac-intel.png
│ ├── download-mac-silicon.png
│ └── download-windows.png
└── void.md
├── examples
├── basics
│ ├── minimal.js
│ ├── introduction.js
│ ├── layers.js
│ ├── animation.js
│ ├── noise.js
│ └── pointer.js
├── package.json
├── tsconfig.json
└── classics
│ ├── 10-print.js
│ ├── ellsworth-kelly.js
│ ├── georg-nees.js
│ ├── bill-kolomyjec.js
│ ├── vera-molnar.js
│ └── francois-morellet.js
├── .gitignore
├── .gitattributes
├── tsconfig.json
├── package.json
└── license.md
/readme.md:
--------------------------------------------------------------------------------
1 | ./packages/void/readme.md
--------------------------------------------------------------------------------
/apps/electron/src/main/index.ts:
--------------------------------------------------------------------------------
1 | import './classes/main'
2 |
--------------------------------------------------------------------------------
/packages/void/src/@types/svgcanvas.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'svgcanvas'
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "semi": false,
4 | "trailingComma": "es5"
5 | }
6 |
--------------------------------------------------------------------------------
/apps/electron/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/packages/void/src/index.ts:
--------------------------------------------------------------------------------
1 | export * as Void from './void'
2 | export * from './config'
3 | export * from './size'
4 | export * from './sketch'
5 |
--------------------------------------------------------------------------------
/docs/images/banner-dark.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:89df3ffb7179819bca3a5314393146e93746577ce26ca9a58c4df9ee14bd1257
3 | size 9257
4 |
--------------------------------------------------------------------------------
/examples/basics/minimal.js:
--------------------------------------------------------------------------------
1 | import { Void } from 'void'
2 |
3 | export default function () {
4 | let ctx = Void.layer()
5 | ctx.fillRect(50, 50, 100, 100)
6 | }
7 |
--------------------------------------------------------------------------------
/docs/images/banner-light.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:709d954f294851670ede300a8a24424de55a9624bd9e7632c6fe338da8beb96b
3 | size 27246
4 |
--------------------------------------------------------------------------------
/docs/images/download-linux.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:988d5bacd7bfa32cbef4b4180c3f024fbd07d964fd84912060650254a9b6355d
3 | size 15346
4 |
--------------------------------------------------------------------------------
/docs/images/introduction.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:7e682820a585b7011523be5a9161137b792b37f7390e155703e6f0158ba84415
3 | size 287266
4 |
--------------------------------------------------------------------------------
/docs/images/recording.gif:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:39cef8cede0e588af22e0a5d6999e2c15fa42f59659e87163560c240ccba0df6
3 | size 1682918
4 |
--------------------------------------------------------------------------------
/apps/electron/resources/icon.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:e6efa2d4c7945e2cad89011b3f1102f5a3aa8e4d6a71859bf2f03d5fbc1103c5
3 | size 228093
4 |
--------------------------------------------------------------------------------
/docs/images/download-mac-intel.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:9e94d715611cac4b9c81398d38b473a8d78374085ba5f9827f4f336c0b5fc428
3 | size 20406
4 |
--------------------------------------------------------------------------------
/docs/images/download-mac-silicon.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:b182b94f9fb92af06bcd97bdb8c00692106284bd88ab06a9b95aae4f7de46e9f
3 | size 27290
4 |
--------------------------------------------------------------------------------
/docs/images/download-windows.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:18d8bf76a4a73c34c9fa38432b5979403585c5451983a54f08579474a8b16696
3 | size 15253
4 |
--------------------------------------------------------------------------------
/apps/electron/resources/installerIcon.ico:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:85304f46ed5f64c6692e36ccc51f2a66d93122af2dd27bf307181f69b6b4c9f7
3 | size 36384
4 |
--------------------------------------------------------------------------------
/apps/electron/resources/uninstallerIcon.ico:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:85304f46ed5f64c6692e36ccc51f2a66d93122af2dd27bf307181f69b6b4c9f7
3 | size 36384
4 |
--------------------------------------------------------------------------------
/apps/electron/src/@types/globals.d.ts:
--------------------------------------------------------------------------------
1 | import { Electron } from '../preload'
2 | import { Void } from 'void'
3 |
4 | declare global {
5 | var electron: Electron
6 | var Void: Void
7 | }
8 |
--------------------------------------------------------------------------------
/packages/void/src/@types/globals.d.ts:
--------------------------------------------------------------------------------
1 | import { Sketch } from '..'
2 |
3 | declare global {
4 | var VOID:
5 | | undefined
6 | | {
7 | sketch?: Sketch
8 | random?: () => number
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/apps/electron/src/@types/env.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace NodeJS {
2 | interface ProcessEnv {
3 | NODE_ENV: 'development' | 'production'
4 | }
5 | }
6 |
7 | interface ImportMeta {
8 | env: {
9 | MODE: 'development' | 'production' | 'preview'
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/examples/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "examples",
3 | "private": true,
4 | "version": "3.0.1",
5 | "dependencies": {
6 | "@types/d3-scale-chromatic": "^3.0.0",
7 | "d3-scale-chromatic": "^3.0.0",
8 | "simplex-noise": "^4.0.1",
9 | "void": "*"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Secrets & configuration
2 | .env*
3 | .vscode/
4 |
5 | # Build outputs
6 | coverage/
7 | dist/
8 | out/
9 | node_modules/
10 |
11 | # Temporary files
12 | .next/
13 | tmp/
14 | *.log
15 | *.tsbuildinfo
16 | apps/site
17 | packages/math
18 | packages/random
19 | .rollup.cache/
--------------------------------------------------------------------------------
/apps/electron/tailwind.config.js:
--------------------------------------------------------------------------------
1 | let colors = require('tailwindcss/colors')
2 |
3 | module.exports = {
4 | mode: 'jit',
5 | content: ['./{src,public,resources}/**/*.{css,html,js,jsx,ts,tsx}'],
6 | theme: {
7 | extend: {
8 | colors: {
9 | gray: colors.zinc,
10 | },
11 | },
12 | },
13 | }
14 |
--------------------------------------------------------------------------------
/apps/electron/test/error.ts:
--------------------------------------------------------------------------------
1 | import { Void } from 'void'
2 |
3 | export default function () {
4 | let { width, height } = Void.settings([300, 300, 'px'])
5 | let context = Void.layer('main')
6 | context.beginPat()
7 | context.arc(width / 2, height / 2, width / 3, 0, Math.PI * 2, false)
8 | context.fill()
9 | }
10 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | #main {
6 | position: relative;
7 | width: 100vw;
8 | height: 100vh;
9 | }
10 |
11 | .app-drag {
12 | -webkit-app-region: drag;
13 | }
14 |
15 | .app-no-drag {
16 | -webkit-app-region: no-drag;
17 | }
18 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/ui/swatch.tsx:
--------------------------------------------------------------------------------
1 | export let Swatch = (props: { fill?: string; stroke?: string }) => {
2 | let { fill = 'transparent', stroke } = props
3 | return (
4 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/packages/void/vite.config.ts:
--------------------------------------------------------------------------------
1 | import Path from 'path'
2 | import { defineConfig } from 'vite'
3 |
4 | export default defineConfig({
5 | build: {
6 | minify: false,
7 | sourcemap: true,
8 | lib: {
9 | entry: Path.resolve(__dirname, './src/index.ts'),
10 | name: 'Void',
11 | fileName: 'void',
12 | formats: ['cjs', 'es'],
13 | },
14 | },
15 | })
16 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.gif filter=lfs diff=lfs merge=lfs -text
2 | *.icns filter=lfs diff=lfs merge=lfs -text
3 | *.ico filter=lfs diff=lfs merge=lfs -text
4 | *.jpeg filter=lfs diff=lfs merge=lfs -text
5 | *.jpg filter=lfs diff=lfs merge=lfs -text
6 | *.mov filter=lfs diff=lfs merge=lfs -text
7 | *.mp4 filter=lfs diff=lfs merge=lfs -text
8 | *.pdf filter=lfs diff=lfs merge=lfs -text
9 | *.png filter=lfs diff=lfs merge=lfs -text
10 | *.webp filter=lfs diff=lfs merge=lfs -text
11 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/contexts/sketch.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from 'react'
2 | import { Sketch } from 'void'
3 |
4 | /** A context for the current Void sketch. */
5 | export let SketchContext = createContext(null)
6 |
7 | /** Use the sketch's entrypoint. */
8 | export let useSketch = (): Sketch => {
9 | let sketch = useContext(SketchContext)
10 | if (!sketch) throw new Error('You must render ')
11 | return sketch
12 | }
13 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 | Void
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/ui/sidebar-button.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export let SidebarButton = (
4 | props: React.ButtonHTMLAttributes
5 | ) => {
6 | let { className = '', ...rest } = props
7 | return (
8 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "references": [
3 | { "path": "./apps/electron" },
4 | { "path": "./packages/void" },
5 | { "path": "./examples" }
6 | ],
7 | "compilerOptions": {
8 | "allowSyntheticDefaultImports": true,
9 | "declaration": true,
10 | "esModuleInterop": false,
11 | "isolatedModules": true,
12 | "jsx": "react-jsx",
13 | "lib": ["dom", "dom.iterable", "esnext"],
14 | "module": "esnext",
15 | "moduleResolution": "node",
16 | "skipLibCheck": true,
17 | "strict": true,
18 | "target": "esnext"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/examples/basics/introduction.js:
--------------------------------------------------------------------------------
1 | import { Void } from 'void'
2 |
3 | export default function () {
4 | // Setup our canvas with specific dimensions.
5 | let { width, height } = Void.settings([300, 300, 'px'])
6 |
7 | // Define a trait that controls the radius of the circle.
8 | let radius = Void.int('radius', 10, 150)
9 |
10 | // Create a new layer to draw on.
11 | let ctx = Void.layer()
12 |
13 | // Draw a circle in the middle of the canvas.
14 | ctx.beginPath()
15 | ctx.arc(width / 2, height / 2, radius, 0, Math.PI * 2, false)
16 | ctx.fill()
17 | }
18 |
--------------------------------------------------------------------------------
/packages/void/rollup.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'rollup'
2 | import typescript from '@rollup/plugin-typescript'
3 | import commonjs from '@rollup/plugin-commonjs'
4 |
5 | export default defineConfig({
6 | input: './src/index.ts',
7 | plugins: [typescript(), commonjs()],
8 | output: [
9 | {
10 | file: './dist/index.mjs',
11 | format: 'esm',
12 | sourcemap: true,
13 | },
14 | {
15 | file: './dist/index.cjs',
16 | format: 'umd',
17 | name: 'Superstruct',
18 | sourcemap: true,
19 | },
20 | ],
21 | })
22 |
--------------------------------------------------------------------------------
/packages/void/src/config/index.ts:
--------------------------------------------------------------------------------
1 | import { Size, Units, Sizes } from '..'
2 |
3 | export * as Config from './methods'
4 |
5 | /** The configuration options for a sketch, with shorthands allowed. */
6 | export type Config = {
7 | dimensions?: Size | Sizes<2>
8 | dpi?: number
9 | fps?: number
10 | frames?: number
11 | margin?: Sizes<1> | Sizes<2> | Sizes<3> | Sizes<4>
12 | orientation?: Orientation
13 | precision?: Sizes<1>
14 | units?: Units
15 | }
16 |
17 | /** The orientation of a set of dimensions. */
18 | export type Orientation = 'square' | 'portrait' | 'landscape'
19 |
--------------------------------------------------------------------------------
/packages/void/test/size.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect as e } from 'vitest'
2 | import { Size } from '../src'
3 |
4 | test('Size.dimensions', () => {
5 | e(Size.dimensions('A4')).toEqual([210, 297, 'mm'])
6 | e(Size.dimensions('1080p')).toEqual([1920, 1080, 'px'])
7 | })
8 |
9 | test('Size.is', () => {
10 | e(Size.is('A4')).toEqual(true)
11 | e(Size.is('1080p')).toEqual(true)
12 | e(Size.is('unknown')).toEqual(false)
13 | })
14 |
15 | test('Size.match', () => {
16 | e(Size.match(8.5, 11, 'in')).toEqual('Letter')
17 | e(Size.match(11, 8.5, 'in')).toEqual('Letter')
18 | e(Size.match(8.5, 8.5, 'in')).toEqual(null)
19 | })
20 |
--------------------------------------------------------------------------------
/apps/electron/src/main/env.ts:
--------------------------------------------------------------------------------
1 | import Path from 'path'
2 | import { app } from 'electron'
3 |
4 | // Operating system
5 | export let IS_MAC = process.platform === 'darwin'
6 | export let IS_WINDOWS = process.platform === 'win32'
7 | export let IS_LINUX = process.platform === 'linux'
8 |
9 | // Development vs. production
10 | export let MODE = import.meta.env.MODE
11 | export let IS_DEV = MODE === 'development'
12 | export let IS_PROD = !IS_DEV
13 |
14 | // The URL to the server entrypoint.
15 | export let RENDERER_URL = app.isPackaged
16 | ? `file://${Path.resolve(__dirname, '../renderer/index.html')}`
17 | : process.env.ELECTRON_RENDERER_URL
18 |
--------------------------------------------------------------------------------
/apps/electron/electron.vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | export default defineConfig({
5 | main: {
6 | build: {
7 | target: 'node16',
8 | watch: {},
9 | },
10 | plugins: [externalizeDepsPlugin()],
11 | },
12 | preload: {
13 | build: {
14 | target: 'node16',
15 | watch: {},
16 | },
17 | plugins: [externalizeDepsPlugin()],
18 | },
19 | renderer: {
20 | build: {
21 | // target: 'chrome',
22 | minify: false,
23 | sourcemap: true,
24 | },
25 | plugins: [react()],
26 | },
27 | })
28 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/ui/icon-button.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export let IconButton = (
4 | props: React.ButtonHTMLAttributes & { active?: boolean }
5 | ) => {
6 | let { active, disabled, className, ...rest } = props
7 | return (
8 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/packages/void/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["./src"],
3 | "exclude": ["./node_modules"],
4 | "compilerOptions": {
5 | "allowSyntheticDefaultImports": true,
6 | "composite": true,
7 | "declaration": true,
8 | "declarationMap": true,
9 | "emitDeclarationOnly": true,
10 | "esModuleInterop": false,
11 | "incremental": true,
12 | "isolatedModules": true,
13 | "jsx": "react-jsx",
14 | "lib": ["dom", "dom.iterable", "esnext"],
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "outDir": "./dist",
18 | "rootDir": "./src",
19 | "skipLibCheck": true,
20 | "strict": true,
21 | "target": "esnext"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/examples/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["./basics", "./classics"],
3 | "references": [{ "path": "../packages/void" }],
4 | "compilerOptions": {
5 | "paths": { "void": ["../packages/void"] },
6 | "allowSyntheticDefaultImports": true,
7 | "composite": true,
8 | "declaration": true,
9 | "declarationMap": true,
10 | "esModuleInterop": false,
11 | "incremental": true,
12 | "isolatedModules": true,
13 | "jsx": "react-jsx",
14 | "lib": ["dom", "dom.iterable", "esnext"],
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "outDir": "./out",
18 | "skipLibCheck": true,
19 | "strict": true,
20 | "target": "esnext"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/apps/electron/src/shared/zoom.ts:
--------------------------------------------------------------------------------
1 | /** The preset known zoom levels. */
2 | export let ZOOM_LEVELS = [
3 | 0.0155125, 0.03125, 0.0625, 0.125, 0.25, 0.5, 1, 2, 4, 8, 16,
4 | ] as const
5 |
6 | /** A known zoom level. */
7 | export type Zoom = typeof ZOOM_LEVELS[number]
8 |
9 | /** Zoom out to the next zoom level. */
10 | export function zoomOut(zoom: number): Zoom {
11 | let next = ZOOM_LEVELS.slice()
12 | .reverse()
13 | .find((z) => z < zoom)
14 | return next ?? ZOOM_LEVELS[0]
15 | }
16 |
17 | /** Zoom in to the next zoom level. */
18 | export function zoomIn(zoom: number): Zoom {
19 | let next = ZOOM_LEVELS.slice().find((z) => z > zoom)
20 | return next ?? ZOOM_LEVELS.at(-1)!
21 | }
22 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/fields/info-field.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export let InfoField = (props: {
4 | label: string
5 | icon?: (props: React.HTMLAttributes) => React.ReactElement
6 | children: React.ReactNode
7 | }) => {
8 | let { icon: Icon, children, label } = props
9 | return (
10 |
11 |
20 | {children}
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/apps/electron/src/shared/store/renderer.ts:
--------------------------------------------------------------------------------
1 | import { Store, createStore } from './base'
2 |
3 | /** Create a store used in Electron renderer processes. */
4 | export function createRendererStore>(
5 | preloadStore: Store
6 | ) {
7 | let isSyncing = false
8 |
9 | // Create a new store that sends patches to the preload process.
10 | let store = createStore(preloadStore.get(), (patches) => {
11 | if (!isSyncing && patches.length > 0) {
12 | preloadStore.patch(patches)
13 | }
14 | })
15 |
16 | // Sync any new patches from the preload process.
17 | preloadStore.subscribe((next, prev, patches) => {
18 | isSyncing = true
19 | store.patch(patches)
20 | isSyncing = false
21 | })
22 |
23 | return store
24 | }
25 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "void-monorepo",
3 | "private": true,
4 | "version": "3.0.1",
5 | "license": "MIT",
6 | "repository": "git://github.com/ianstormtaylor/void.git",
7 | "workspaces": [
8 | "apps/*",
9 | "packages/*",
10 | "examples"
11 | ],
12 | "scripts": {
13 | "clean": "rm -rf ./node_modules && rm -rf ./{apps,packages}/*/{.rollup.cache,coverage,dist,node_modules,out,tsconfig.tsbuildinfo}",
14 | "publish": "npm run --workspaces --if-present publish",
15 | "version": "npm version --workspaces --include-workspace-root --allow-same-version --git-tag-version=false"
16 | },
17 | "devDependencies": {
18 | "prettier": "^2.6.2",
19 | "typescript": "^4.9.3",
20 | "vite": "^3.1.8",
21 | "vitest": "^0.24.3"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/apps/electron/scripts/notarize.js:
--------------------------------------------------------------------------------
1 | const { notarize } = require('electron-notarize')
2 |
3 | exports.default = async function notarizing(context) {
4 | if (
5 | // Only notarize on macOS.
6 | context.electronPlatformName === 'darwin' &&
7 | // When set to null it's to skip the signing and notarizing step.
8 | context.packager.platformSpecificBuildOptions.identity !== null
9 | ) {
10 | console.log('Notarizing macOS app…')
11 | return await notarize({
12 | tool: 'notarytool',
13 | appPath: `${context.appOutDir}/${context.packager.appInfo.productFilename}.app`,
14 | appleId: process.env.APPLE_ID,
15 | appleIdPassword: process.env.APPLE_ID_APP_SPECIFIC_PASSWORD,
16 | teamId: process.env.APPLE_ID_TEAM_ID,
17 | })
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/examples/basics/layers.js:
--------------------------------------------------------------------------------
1 | import { Void } from 'void'
2 |
3 | export default function () {
4 | // Setup a square canvas.
5 | let { width } = Void.settings([300, 300, 'px'])
6 |
7 | // Define some variables.
8 | let center = width / 2
9 | let diameter = width / 4
10 | let offset = width / 10
11 |
12 | // Create the bottom layer with a red circle on it.
13 | let one = Void.layer()
14 | one.fillStyle = 'red'
15 | one.beginPath()
16 | one.arc(center - offset, center - offset, diameter, 0, Math.PI * 2, false)
17 | one.fill()
18 |
19 | // Create the top layer with a blue circle on it.
20 | let two = Void.layer()
21 | two.fillStyle = 'blue'
22 | two.beginPath()
23 | two.arc(center + offset, center + offset, diameter, 0, Math.PI * 2, false)
24 | two.fill()
25 | }
26 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/fields/color-field.tsx:
--------------------------------------------------------------------------------
1 | import { Swatch } from '../ui/swatch'
2 |
3 | export let ColorField = (props: {
4 | value: string
5 | label: string
6 | onChange: (value: string) => void
7 | }) => {
8 | let { value, label, onChange } = props
9 | return (
10 |
11 |
17 | onChange(value)}
20 | className="w-full py-1.5 px-2.5 pl-8 rounded border border-transparent hover:border-gray-200"
21 | />
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/editor-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { SeedPanel } from './panels/seed-panel'
2 | import { ExportPanel } from './panels/export-panel'
3 | import { TraitsPanel } from './panels/traits-panel'
4 | import { LayoutPanel } from './panels/layout-panel'
5 | import { LayersPanel } from './panels/layers-panel'
6 |
7 | export let EditorSidebar = () => {
8 | return (
9 |
10 |
11 | {/*
*/}
12 |
13 | {/*
*/}
14 |
15 | {/*
*/}
16 |
17 | {/*
*/}
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/examples/classics/10-print.js:
--------------------------------------------------------------------------------
1 | import { Void } from 'void'
2 |
3 | export default function () {
4 | let cell = Void.float('cell', 0.1, 1, 0.1)
5 | let weight = Void.float('weight', 0.01, 0.07, 0.01)
6 | let cap = Void.pick('cap', ['square', 'round', 'butt'])
7 |
8 | let { width, height } = Void.settings({
9 | dimensions: [8, 6, 'in'],
10 | margin: [0.25, 'in'],
11 | })
12 |
13 | let ctx = Void.layer()
14 | ctx.lineCap = cap
15 | ctx.lineWidth = weight
16 |
17 | for (var x = 0; x < width; x += cell) {
18 | for (var y = 0; y < height; y += cell) {
19 | ctx.beginPath()
20 | if (Void.random() < 0.5) {
21 | ctx.moveTo(x, y)
22 | ctx.lineTo(x + cell, y + cell)
23 | } else {
24 | ctx.moveTo(x + cell, y)
25 | ctx.lineTo(x, y + cell)
26 | }
27 | ctx.stroke()
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/apps/electron/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["./scripts", "./src", "./test"],
3 | "exclude": ["./node_modules"],
4 | "references": [{ "path": "../../packages/void" }],
5 | "compilerOptions": {
6 | "paths": { "void": ["../../packages/void"] },
7 | "allowJs": false,
8 | "allowSyntheticDefaultImports": true,
9 | "composite": true,
10 | "declaration": true,
11 | "declarationMap": true,
12 | "esModuleInterop": false,
13 | "forceConsistentCasingInFileNames": true,
14 | "incremental": true,
15 | "isolatedModules": true,
16 | "jsx": "react-jsx",
17 | "lib": ["dom", "dom.iterable", "esnext"],
18 | "module": "esnext",
19 | "moduleResolution": "node",
20 | "outDir": "./dist",
21 | "resolveJsonModule": true,
22 | "skipLibCheck": true,
23 | "strict": true,
24 | "target": "esnext",
25 | "useDefineForClassFields": true
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/contexts/tab.tsx:
--------------------------------------------------------------------------------
1 | import { TabState } from '../../shared/store-state'
2 | import { createContext, useCallback, useContext } from 'react'
3 | import { useStore } from './store'
4 | import { Changer, Producer } from '../utils'
5 |
6 | /** A context for the sketch's tab. */
7 | export let TabContext = createContext(null)
8 |
9 | /** Use the sketch's tab. */
10 | export let useTab = (): [TabState, Changer] => {
11 | let tab = useContext(TabContext)
12 | let [, changeStore] = useStore()
13 | let changeTab = useCallback(
14 | (recipe: Producer) => {
15 | changeStore((c) => {
16 | if (!tab) return
17 | recipe(c.tabs[tab.id])
18 | })
19 | },
20 | [tab, changeStore]
21 | )
22 |
23 | if (!tab) {
24 | throw new Error('You must render ')
25 | }
26 |
27 | return [tab, changeTab]
28 | }
29 |
--------------------------------------------------------------------------------
/packages/void/src/size/methods.ts:
--------------------------------------------------------------------------------
1 | import { Size, Sizes, SIZES } from '..'
2 |
3 | /** Get the dimensions for a size. */
4 | export function dimensions(size: Size): Sizes<2> {
5 | if (is(size)) return SIZES[size].slice() as Sizes<2>
6 | throw new Error(`Unrecognized size keyword: "${size}"`)
7 | }
8 |
9 | /** Check if a `value` is a size keyword. */
10 | export function is(value: unknown): value is Size {
11 | return typeof value === 'string' && value in SIZES
12 | }
13 |
14 | /** Try to match a `width`, `height`, and `units` to a size keyword. */
15 | export function match(
16 | width: number,
17 | height: number,
18 | units: string
19 | ): Size | null {
20 | for (let [size, [w, h, u]] of Object.entries(SIZES)) {
21 | if (u !== units) continue
22 | if ((w !== width || h !== height) && (w !== height || h !== width)) continue
23 | return size as Size
24 | }
25 |
26 | return null
27 | }
28 |
--------------------------------------------------------------------------------
/apps/electron/resources/entitlements.mac.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.cs.allow-dyld-environment-variables
6 |
7 | com.apple.security.cs.disable-library-validation
8 |
9 | com.apple.security.cs.allow-jit
10 |
11 | com.apple.security.cs.allow-unsigned-executable-memory
12 |
13 | com.apple.security.cs.debugger
14 |
15 | com.apple.security.network.client
16 |
17 | com.apple.security.network.server
18 |
19 | com.apple.security.files.user-selected.read-only
20 |
21 | com.apple.security.inherit
22 |
23 |
24 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/contexts/window.tsx:
--------------------------------------------------------------------------------
1 | import { WindowState } from '../../shared/store-state'
2 | import { createContext, useCallback, useContext } from 'react'
3 | import { useStore } from './store'
4 | import { Changer, Producer } from '../utils'
5 |
6 | /** A context for the app's window. */
7 | export let WindowContext = createContext(null)
8 |
9 | /** Use the app's window. */
10 | export let useWindow = (): [WindowState, Changer] => {
11 | let win = useContext(WindowContext)
12 | let [, changeStore] = useStore()
13 | let changeWindow = useCallback(
14 | (recipe: Producer) => {
15 | changeStore((c) => {
16 | if (!win) return
17 | recipe(c.windows[win.id])
18 | })
19 | },
20 | [win, changeStore]
21 | )
22 |
23 | if (!win) {
24 | throw new Error('You must render ')
25 | }
26 |
27 | return [win, changeWindow]
28 | }
29 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/fields/margins-field.tsx:
--------------------------------------------------------------------------------
1 | import { MdSelectAll } from 'react-icons/md'
2 | import { Config, Sketch } from 'void'
3 |
4 | export let MarginsField = (props: { sketch: Sketch }) => {
5 | let { sketch } = props
6 | let { config } = sketch
7 | let margin = Config.margin(config)
8 | let values: number[] = []
9 | let [t, r, b, l, units] = margin
10 |
11 | if (t == r && t == b && t == l) {
12 | values.push(t)
13 | } else if (t == b && r == l) {
14 | values.push(t, r)
15 | } else {
16 | values.push(t, r, b, l)
17 | }
18 |
19 | return (
20 |
21 |
25 |
26 |
27 |
28 |
29 | {values.join(', ')} {units}
30 |
31 |
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/examples/basics/animation.js:
--------------------------------------------------------------------------------
1 | import { Void } from 'void'
2 |
3 | export default function () {
4 | // Get the canvas's dimensions and setup some variables.
5 | let { width, height } = Void.settings()
6 | let y = 0
7 |
8 | // Define a `speed` trait that will control how fact our line moves.
9 | let speed = Void.int('speed', 1, 11)
10 |
11 | // Create a layer to draw on, and fill it with black.
12 | let context = Void.layer()
13 | context.fillStyle = 'black'
14 | context.fillRect(0, 0, width, height)
15 |
16 | // On each frame…
17 | Void.draw(() => {
18 | // Fade the previous frame slightly more towards black.
19 | context.fillStyle = 'rgba(0, 0, 0, 0.25)'
20 | context.fillRect(0, 0, width, height)
21 |
22 | // Draw a new horizontal line.
23 | context.strokeStyle = 'gray'
24 | context.beginPath()
25 | context.moveTo(0, y)
26 | context.lineTo(width, y)
27 | context.stroke()
28 |
29 | // Move downward, and wrap back to the top.
30 | y = y >= height ? 0 : y + speed
31 | })
32 | }
33 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/contexts/entrypoint.tsx:
--------------------------------------------------------------------------------
1 | import { EntrypointState } from '../../shared/store-state'
2 | import { createContext, useCallback, useContext } from 'react'
3 | import { useStore } from './store'
4 | import { Changer, Producer } from '../utils'
5 |
6 | /** A context for the sketch's entrypoint. */
7 | export let EntrypointContext = createContext(null)
8 |
9 | /** Use the sketch's entrypoint. */
10 | export let useEntrypoint = (): [EntrypointState, Changer] => {
11 | let entrypoint = useContext(EntrypointContext)
12 | let [, changeStore] = useStore()
13 | let changeEntrypoint = useCallback(
14 | (recipe: Producer) => {
15 | changeStore((c) => {
16 | if (!entrypoint) return
17 | recipe(c.entrypoints[entrypoint.id])
18 | })
19 | },
20 | [entrypoint, changeStore]
21 | )
22 |
23 | if (!entrypoint) {
24 | throw new Error('You must render ')
25 | }
26 |
27 | return [entrypoint, changeEntrypoint]
28 | }
29 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/contexts/store.tsx:
--------------------------------------------------------------------------------
1 | import { enablePatches } from 'immer'
2 | import { useCallback, useEffect, useMemo, useState } from 'react'
3 | import { StoreState } from '../../shared/store-state'
4 | import { createRendererStore } from '../../shared/store/renderer'
5 |
6 | // Enable patches in this process's immer package.
7 | enablePatches()
8 |
9 | // Create a renderer store that proxies to the preload store.
10 | let store = createRendererStore(electron.store)
11 |
12 | /** Use the synchronized store's state as a hook. */
13 | export let useStore = (): readonly [
14 | StoreState,
15 | (recipe: (draft: StoreState) => StoreState | void) => void
16 | ] => {
17 | let [count, setCount] = useState(0)
18 | let value = useMemo(() => store.get(), [count])
19 | let setValue = useCallback(
20 | (recipe: (draft: StoreState) => StoreState | void) => {
21 | store.change(recipe)
22 | },
23 | []
24 | )
25 |
26 | useEffect(() => {
27 | return store.subscribe(() => setCount(count++))
28 | }, [])
29 |
30 | return [value, setValue]
31 | }
32 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/fields/boolean-field.tsx:
--------------------------------------------------------------------------------
1 | export let BooleanField = (props: {
2 | value: boolean
3 | label: string
4 | valueClassName?: string
5 | onChange: (value: boolean) => void
6 | }) => {
7 | let { value, label, onChange, valueClassName = '' } = props
8 | return (
9 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/examples/classics/ellsworth-kelly.js:
--------------------------------------------------------------------------------
1 | import { Void } from 'void'
2 |
3 | // https://www.moma.org/collection/works/35484
4 | export default function () {
5 | let { width, height } = Void.settings({ dimensions: [36, 36, 'in'] })
6 | let bg = Void.layer('background')
7 | bg.fillStyle = '#393939'
8 | bg.fillRect(0, 0, width, height)
9 |
10 | let ctx = Void.layer('tiles')
11 | let grid = Void.int('grid', 12, 36)
12 | let count = Void.int('count', 200, 1000, 200)
13 | let cell = width / grid
14 | let palette = [
15 | '#D4AB97',
16 | '#A2A757',
17 | '#2E3580',
18 | '#95393E',
19 | '#DAC34F',
20 | '#86A2BA',
21 | '#654470',
22 | '#BE6C22',
23 | '#4053A3',
24 | '#BE4E39',
25 | '#D79500',
26 | '#C7C796',
27 | '#394E5F',
28 | '#C4BEBE',
29 | '#7A9A82',
30 | ]
31 |
32 | for (let i = 0; i < count; i++) {
33 | let x = Void.random(0, grid, 1) * cell
34 | let y = Void.random(0, grid, 1) * cell
35 | ctx.fillStyle = palette[Math.floor(Void.random() * palette.length)]
36 | ctx.fillRect(x, y, cell, cell)
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/apps/electron/src/main/ipc.ts:
--------------------------------------------------------------------------------
1 | import { ipcMain } from 'electron'
2 | import { main } from './classes/main'
3 | import { Tab } from './classes/tab'
4 | import { Window } from './classes/window'
5 |
6 | export let initializeIpc = () => {
7 | ipcMain.handle('getWindow', (e) => {
8 | let window = Window.bySenderId(e.sender.id)
9 | let json = main.store.windows[window.id]
10 | return json
11 | })
12 |
13 | ipcMain.handle('activateTab', (e, id) => {
14 | let window = Window.bySenderId(e.sender.id)
15 | window.activateTab(id)
16 | })
17 |
18 | ipcMain.handle('closeTab', (e, id) => {
19 | let window = Window.bySenderId(e.sender.id)
20 | window.closeTab(id)
21 | })
22 |
23 | ipcMain.handle('inspectTab', (e, id) => {
24 | let tab = Tab.byId(id)
25 | tab.inspect()
26 | })
27 |
28 | ipcMain.handle('open', (e) => {
29 | main.open()
30 | })
31 |
32 | ipcMain.handle('openFiles', (e, paths) => {
33 | let window = Window.bySenderId(e.sender.id)
34 | for (let path of paths) {
35 | window.openTab(path)
36 | window.show()
37 | }
38 | })
39 | }
40 |
--------------------------------------------------------------------------------
/license.md:
--------------------------------------------------------------------------------
1 | The MIT License
2 |
3 | Copyright © 2022, [Ian Storm Taylor](https://ianstormtaylor.com)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/apps/electron/src/shared/store-state.ts:
--------------------------------------------------------------------------------
1 | import { Config } from 'void'
2 |
3 | /** The saved state of a `Window`. */
4 | export type WindowState = {
5 | id: string
6 | tabIds: string[]
7 | activeTabId: string | null
8 | x: number
9 | y: number
10 | width: number
11 | height: number
12 | }
13 |
14 | /** The saved state of a `Tab`. */
15 | export type TabState = {
16 | id: string
17 | entrypointId: string
18 | zoom: number | null
19 | seed: number
20 | config: Config
21 | traits: Record
22 | layers: Record
23 | }
24 |
25 | /** The saved state of a `Entrypoint`. */
26 | export type EntrypointState = {
27 | id: string
28 | path: string
29 | url: string | null
30 | timestamp: number | null
31 | }
32 |
33 | /** The saved state of the entire app. */
34 | export type StoreState = {
35 | entrypoints: Record
36 | tabs: Record
37 | windows: Record
38 | }
39 |
40 | /** The initial empty state of the app. */
41 | export let initialState: StoreState = {
42 | entrypoints: {},
43 | tabs: {},
44 | windows: {},
45 | }
46 |
--------------------------------------------------------------------------------
/examples/classics/georg-nees.js:
--------------------------------------------------------------------------------
1 | import { Void } from 'void'
2 |
3 | // https://collections.vam.ac.uk/item/O221321/schotter-print-nees-georg/
4 | // http://www.medienkunstnetz.de/works/schotter/
5 | export default function () {
6 | let { width, height } = Void.settings({
7 | dimensions: [4, 7, 'in'],
8 | margin: [0.25, 'in'],
9 | units: 'pt',
10 | })
11 |
12 | let ctx = Void.layer()
13 | let cols = Void.int('columns', 4, 16)
14 | let disp = Void.int('displacement', 0, 25)
15 | let rot = Void.int('rotation', 0, 180, 30) * (Math.PI / 180)
16 | let cell = width / cols
17 | let half = cell / 2
18 |
19 | for (let x = 0; x < width; x += cell) {
20 | for (let y = 0; y < height - cell; y += cell) {
21 | let t = Math.max(y / height, 0.07) ** 1.5
22 | let angle = t * Void.random(-rot, rot)
23 | let ox = t * Void.random(-disp, disp)
24 | let oy = t * Void.random(-disp, disp)
25 | ctx.save()
26 | ctx.translate(x + ox + half, y + oy + half)
27 | ctx.rotate(angle)
28 | ctx.translate(-half, -half)
29 | ctx.strokeRect(0, 0, cell, cell)
30 | ctx.restore()
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/apps/electron/src/shared/store/preload.ts:
--------------------------------------------------------------------------------
1 | import { Patch } from 'immer'
2 | import { ipcRenderer } from 'electron'
3 | import { CHANGE_CHANNEL, CONNECT_CHANNEL } from './base'
4 | import { createStore } from './base'
5 |
6 | /** Create a store used in Electron renderer processes. */
7 | export function createPreloadStore>(
8 | initialState: T
9 | ) {
10 | let isSyncing = false
11 | let store = createStore(initialState, (patches) => {
12 | if (!isSyncing && patches.length > 0) {
13 | ipcRenderer.send(CHANGE_CHANNEL, patches)
14 | }
15 | })
16 |
17 | // When the renderer first connects, it will get sent the current state.
18 | ipcRenderer.on(CONNECT_CHANNEL, (e, state: T) => {
19 | isSyncing = true
20 | store.change(() => state)
21 | isSyncing = false
22 | })
23 |
24 | // When the renderer receives a change, apply it and emit.
25 | ipcRenderer.on(CHANGE_CHANNEL, (e, patches: Patch[]) => {
26 | isSyncing = true
27 | store.patch(patches)
28 | isSyncing = false
29 | })
30 |
31 | // Ask for the current state.
32 | ipcRenderer.send(CONNECT_CHANNEL)
33 | return store
34 | }
35 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/window.tsx:
--------------------------------------------------------------------------------
1 | import { MdAdd, MdOpenInBrowser } from 'react-icons/md'
2 | import { useWindow } from '../contexts/window'
3 | import { WindowTabs } from './window-tabs'
4 |
5 | export let Window = () => {
6 | let [window] = useWindow()
7 | let hasTabs = window.tabIds.length > 0
8 | return (
9 |
10 |
11 | {hasTabs ? null : (
12 |
13 |
14 |
23 |
24 | Or drag a sketch file here.
25 |
26 |
27 |
28 | )}
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/examples/classics/bill-kolomyjec.js:
--------------------------------------------------------------------------------
1 | import { Void } from 'void'
2 |
3 | // https://www.atariarchives.org/artist/sec15.php
4 | // http://recodeproject.com/artwork/v2n3random-squares
5 | // https://github.s3.amazonaws.com/downloads/matthewepler/ReCode_Project/COMPUTER_GRAPHICS_AND_ART_Aug1977.pdf
6 | export default function () {
7 | let rows = Void.int('rows', 3, 7)
8 | let cols = Void.int('cols', 3, 7)
9 | let focus = Void.pick('focus', [3, 5, 6, 12, 18])
10 | let cell = 60
11 |
12 | let { width, height } = Void.settings({
13 | dimensions: [rows * cell, cols * cell, 'mm'],
14 | margin: [cell / 2, 'mm'],
15 | })
16 |
17 | let ctx = Void.layer()
18 |
19 | for (let x = 0; x < width; x += cell) {
20 | for (let y = 0; y < height; y += cell) {
21 | let size = cell
22 | let top = x
23 | let left = y
24 | let steps = Void.random(1, 8, 1)
25 | let step = (size - focus) / steps
26 | let xdir = Void.random(-1, 1, 1)
27 | let ydir = Void.random(-1, 1, 1)
28 | while (size >= focus) {
29 | ctx.strokeRect(top, left, size, size)
30 | top += step / 2 + (step / 4) * xdir
31 | left += step / 2 + (step / 4) * ydir
32 | size -= step
33 | }
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/apps/electron/src/shared/store/main.ts:
--------------------------------------------------------------------------------
1 | import { Patch } from 'immer'
2 | import { ipcMain, webContents } from 'electron'
3 | import { CHANGE_CHANNEL, CONNECT_CHANNEL } from './base'
4 | import { createStore } from './base'
5 |
6 | /** Create a store used in the Electron main process. */
7 | export function createMainStore>(
8 | initialState: T
9 | ) {
10 | let senderId: number | null = null
11 | let connections = new Set()
12 | let store = createStore(initialState, (patches) => {
13 | if (patches.length > 0) {
14 | for (let id of connections) {
15 | if (senderId != null && id === senderId) continue
16 | let w = webContents.fromId(id)
17 | if (w) w.send(CHANGE_CHANNEL, patches)
18 | }
19 | }
20 | })
21 |
22 | // When a renderer connects, send it the current state and store its id.
23 | ipcMain.on(CONNECT_CHANNEL, (e) => {
24 | e.sender.send(CONNECT_CHANNEL, store.get())
25 | connections.add(e.sender.id)
26 | })
27 |
28 | // When a renderer sends changes, apply them and broadcast to others.
29 | ipcMain.on(CHANGE_CHANNEL, (e, patches: Patch[]) => {
30 | senderId = e.sender.id
31 | store.patch(patches)
32 | senderId = null
33 | })
34 |
35 | return store
36 | }
37 |
--------------------------------------------------------------------------------
/examples/basics/noise.js:
--------------------------------------------------------------------------------
1 | import { Void } from 'void'
2 | import { createNoise2D } from 'simplex-noise'
3 |
4 | export default function () {
5 | let noise = createNoise2D(Void.random)
6 | let { width, height } = Void.settings([400, 400, 'px'])
7 | let ctx = Void.layer()
8 |
9 | let size = Void.int('size', 2, 10, 2)
10 | let octaves = Void.int('octaves', 1, 6)
11 | let amplitude = Void.int('amplitude', 1, 20)
12 | let persistence = Void.float('persistence', 0.1, 1, 0.1)
13 | let frequency = Void.int('frequency', 1, 24)
14 | let lacunarity = Void.int('lacunarity', 2, 16, 2)
15 |
16 | for (let x = 0; x < width; x += size) {
17 | for (let y = 0; y < height; y += size) {
18 | let tx = x / width
19 | let ty = y / height
20 | let sum = 0
21 | let max = 0
22 | let a = amplitude
23 | let f = frequency
24 |
25 | for (let o = 0; o < octaves; o++) {
26 | let n = noise(tx * f, ty * f)
27 | sum += n * a
28 | max += a
29 | a *= persistence
30 | f *= lacunarity
31 | }
32 |
33 | sum /= 2 - 1 / 2 ** (octaves - 1)
34 | let v = sum / max
35 | let l = Math.round(((v + 1) / 2) * 100)
36 | ctx.fillStyle = `hsl(0, 0%, ${l}%)`
37 | ctx.fillRect(x, y, size, size)
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/examples/classics/vera-molnar.js:
--------------------------------------------------------------------------------
1 | import { Void } from 'void'
2 |
3 | // https://pratiques-picturales.net/article63.html
4 | // https://display.org.es/en/work/forms
5 | // https://opheliaming.medium.com/vera-moln%C3%A1r-the-computer-art-goddess-26a84efbea4b
6 | // https://trendland.com/art-paris-2014/
7 | export default function () {
8 | let { width } = Void.settings({
9 | dimensions: [36, 36, 'cm'],
10 | margin: [3, 'cm'],
11 | })
12 |
13 | let grid = Void.int('grid', 10, 50, 5)
14 | let cell = width / grid
15 | let half = cell / 2
16 | let positions = [[0.5], [0.2, 0.8], [0.1, 0.5, 0.9]]
17 |
18 | let ctx = Void.layer()
19 | ctx.lineWidth = 0.1
20 | ctx.lineCap = 'round'
21 |
22 | for (let col = 0; col < grid; col++) {
23 | for (let row = 0; row < grid; row++) {
24 | let x = col * cell
25 | let y = row * cell
26 | let a = Void.random(0, Math.PI * 2)
27 | let i = Math.floor((row / grid) * positions.length)
28 | let ps = positions[i]
29 |
30 | ctx.save()
31 | ctx.translate(x + half, y + half)
32 | ctx.rotate(a)
33 | ctx.translate(-half, -half)
34 | for (let p of ps) {
35 | ctx.beginPath()
36 | ctx.moveTo(p * cell, 0)
37 | ctx.lineTo(p * cell, cell)
38 | ctx.stroke()
39 | }
40 | ctx.restore()
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/apps/electron/src/preload/index.ts:
--------------------------------------------------------------------------------
1 | import { contextBridge, ipcRenderer } from 'electron'
2 | import { initialState } from '../shared/store-state'
3 | import { createPreloadStore } from '../shared/store/preload'
4 |
5 | /** A shared store. */
6 | let store = createPreloadStore(initialState)
7 |
8 | /** The global object exposed to use Electron APIs. */
9 | export type Electron = typeof electron
10 | export let electron = {
11 | store,
12 | activateTab,
13 | closeTab,
14 | inspectTab,
15 | open,
16 | openFiles,
17 | }
18 |
19 | // @ts-ignore
20 | // window.electron = electron
21 | contextBridge.exposeInMainWorld('electron', electron)
22 |
23 | /** Active a tab by `id`. */
24 | function activateTab(id: string): Promise {
25 | return ipcRenderer.invoke('activateTab', id)
26 | }
27 |
28 | /** Close a tab by `id`. */
29 | function closeTab(id: string): Promise {
30 | return ipcRenderer.invoke('closeTab', id)
31 | }
32 |
33 | /** Inspect a tab by `id`. */
34 | function inspectTab(id: string): Promise {
35 | return ipcRenderer.invoke('inspectTab', id)
36 | }
37 |
38 | /** Open new tabs. */
39 | function open(): Promise {
40 | return ipcRenderer.invoke('open')
41 | }
42 |
43 | /** Open new files by `paths`. */
44 | function openFiles(paths: string[]): Promise {
45 | return ipcRenderer.invoke('openFiles', paths)
46 | }
47 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/utils.ts:
--------------------------------------------------------------------------------
1 | import { Draft } from 'immer'
2 |
3 | /** A simplified `Producer` type for Immer. */
4 | export type Producer = (draft: Draft) => void
5 |
6 | /** An Immer change function. */
7 | export type Changer = (recipe: Producer) => void
8 |
9 | export function hashSeed(seed: number): string {
10 | return `0x${[
11 | hashInt(seed),
12 | hashInt(seed + 1),
13 | hashInt(seed + 2),
14 | hashInt(seed + 3),
15 | ]
16 | .map((n) => n.toString(16).padStart(8, '0'))
17 | .join('')}`
18 | }
19 |
20 | export function unhashSeed(hash: string): number {
21 | return unhashInt(Number(hash.slice(0, 10)))
22 | }
23 |
24 | /** Hash an integer `x` into another integer, from `1` to `2^32`. */
25 | export function hashInt(x: number): number {
26 | // https://github.com/skeeto/hash-prospector
27 | x = (x ^ (x >>> 16)) >>> 0
28 | x = Math.imul(x, 569420461)
29 | x = (x ^ (x >>> 15)) >>> 0
30 | x = Math.imul(x, 1935289751)
31 | x = (x ^ (x >>> 15)) >>> 0
32 | return x
33 | }
34 |
35 | /** Un-hash an integer `x` back into another integer, from `1` to `2^32`. */
36 | export function unhashInt(x: number): number {
37 | // https://github.com/skeeto/hash-prospector
38 | x = (x ^ (x >>> 15) ^ (x >>> 30)) >>> 0
39 | x = Math.imul(x, 2534613543)
40 | x = (x ^ (x >>> 15) ^ (x >>> 30)) >>> 0
41 | x = Math.imul(x, 859588901)
42 | x = (x ^ (x >>> 16)) >>> 0
43 | return x
44 | }
45 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/panels/traits-panel.tsx:
--------------------------------------------------------------------------------
1 | import plur from 'plur'
2 | import { MdClear } from 'react-icons/md'
3 | import { useSketch } from '../../contexts/sketch'
4 | import { useTab } from '../../contexts/tab'
5 | import { TraitField } from '../fields/trait-field'
6 | import { IconButton } from '../ui/icon-button'
7 | import { SidebarPanel } from '../ui/sidebar-panel'
8 |
9 | export let TraitsPanel = () => {
10 | let sketch = useSketch()
11 | let [tab, changeTab] = useTab()
12 | let count = Object.keys(sketch.traits).length
13 | let sketchHas = count > 0
14 | let tabHas = Object.keys(tab.traits).length > 0
15 | return (
16 | 0}
19 | summary={`${count} ${plur('trait', count)}`}
20 | buttons={
21 | <>
22 | {
26 | changeTab((t) => {
27 | t.traits = {}
28 | })
29 | }}
30 | >
31 |
32 |
33 | >
34 | }
35 | >
36 | {sketchHas ? (
37 | Object.keys(sketch.schemas ?? {}).map((name) => (
38 |
39 | ))
40 | ) : (
41 | No traits were defined.
42 | )}
43 |
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/fields/layer-field.tsx:
--------------------------------------------------------------------------------
1 | import { EyeIcon, EyeOffIcon } from '@heroicons/react/outline'
2 | import { capitalCase } from 'change-case'
3 |
4 | export let LayerField = (props: {
5 | name: string
6 | fill?: string
7 | stroke?: string
8 | hidden: boolean
9 | toggle: () => void
10 | }) => {
11 | let { name, fill, stroke, hidden, toggle } = props
12 | return (
13 |
14 |
15 | {fill == null ? (
16 |
19 | ) : (
20 |
28 | )}
29 |
{capitalCase(name ?? 'Untitled Layer')}
30 |
31 |
44 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/fields/enum-field.tsx:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react'
2 |
3 | export let EnumField = (props: {
4 | label: string
5 | value: string
6 | options: string[]
7 | valueClassName?: string
8 | onChange: (value: any) => void
9 | }) => {
10 | let { value, options, label, onChange, valueClassName = '' } = props
11 | let selectRef = useRef(null)
12 | return (
13 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/panels/seed-panel.tsx:
--------------------------------------------------------------------------------
1 | import { NumberField } from '../fields/number-field'
2 | import { MdEast, MdFingerprint, MdWest } from 'react-icons/md'
3 | import { useTab } from '../../contexts/tab'
4 | import { SidebarPanel } from '../ui/sidebar-panel'
5 | import { IconButton } from '../ui/icon-button'
6 | import { useSketch } from '../../contexts/sketch'
7 | import { useCallback } from 'react'
8 | import { unhashInt, unhashSeed } from '../../utils'
9 |
10 | export const SeedPanel = () => {
11 | const [, changeTab] = useTab()
12 | const sketch = useSketch()
13 | const seed = unhashSeed(sketch.hash)
14 | const min = 1
15 | const max = 2 ** 32
16 | const setSeed = useCallback(
17 | (s: number) => {
18 | changeTab((t) => {
19 | t.seed = Math.min(Math.max(s, min), max)
20 | })
21 | },
22 | [changeTab, min, max]
23 | )
24 |
25 | return (
26 |
31 | setSeed(seed - 1)}>
32 |
33 |
34 | setSeed(seed + 1)}>
35 |
36 |
37 | >
38 | }
39 | >
40 | }
42 | label="Seed"
43 | value={seed}
44 | step={1}
45 | min={min}
46 | max={max}
47 | onChange={(seed) => setSeed(seed)}
48 | />
49 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/fields/dimensions-field.tsx:
--------------------------------------------------------------------------------
1 | import { MdCrop } from 'react-icons/md'
2 | import { Config, Sketch, Size } from 'void'
3 |
4 | export let DimensionsField = (props: { sketch: Sketch }) => {
5 | let { sketch } = props
6 | let { config } = sketch
7 | let orientation = Config.orientation(config)
8 | let dimensions = Config.dimensions(config)
9 | let [width, height, units] = dimensions
10 | let infinite = false
11 |
12 | if (width === Infinity || height === Infinity) {
13 | ;[width, height] = Sketch.dimensions(sketch)
14 | units = sketch.settings.units
15 | infinite = true
16 | }
17 |
18 | let size = Size.match(width, height, units)
19 | let max = Math.max(width, height)
20 | let min = Math.min(width, height)
21 |
22 | if (orientation === 'portrait') {
23 | ;[width, height] = [min, max]
24 | } else if (orientation === 'landscape') {
25 | ;[width, height] = [max, min]
26 | } else if (orientation === 'square') {
27 | ;[width, height] = [min, min]
28 | }
29 |
30 | return (
31 |
32 |
36 |
37 |
38 |
39 |
40 | {width} × {height} {units}
41 |
42 | {infinite && (Fullscreen)}
43 | {size && ({size})}
44 |
45 |
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/ui/sidebar-panel.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { MdChevronRight, MdExpandMore } from 'react-icons/md'
3 |
4 | export let SidebarPanel = (props: {
5 | title: React.ReactNode
6 | children: React.ReactNode
7 | buttons?: React.ReactNode
8 | summary?: React.ReactNode
9 | className?: string
10 | initialExpanded?: boolean
11 | }) => {
12 | let {
13 | title,
14 | children,
15 | buttons = null,
16 | summary = null,
17 | className = '',
18 | initialExpanded = true,
19 | } = props
20 | let [expanded, setExpanded] = useState(initialExpanded)
21 | return (
22 |
28 |
29 |
setExpanded(!expanded)}
32 | >
33 |
{title}
34 |
40 | {expanded ? : }
41 |
42 |
43 |
44 | {expanded ? (
45 |
{buttons}
46 | ) : (
47 |
{summary}
48 | )}
49 |
50 |
51 | {expanded && children}
52 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/panels/export-panel.tsx:
--------------------------------------------------------------------------------
1 | import { OutputType, Sketch } from 'void'
2 | import { useCallback } from 'react'
3 | import { useEntrypoint } from '../../contexts/entrypoint'
4 | import { SidebarButton } from '../ui/sidebar-button'
5 | import { SidebarPanel } from '../ui/sidebar-panel'
6 | import { useSketch } from '../../contexts/sketch'
7 |
8 | export let ExportPanel = () => {
9 | let [entrypoint] = useEntrypoint()
10 | let sketch = useSketch()
11 | let onDownload = useCallback(
12 | (type: OutputType) => {
13 | let div = document.createElement('div')
14 | let s = Sketch.of({
15 | construct: sketch.construct,
16 | container: div,
17 | hash: sketch.hash,
18 | traits: sketch.traits,
19 | layers: sketch.layers,
20 | config: sketch.config,
21 | output: { type, quality: 1 },
22 | })
23 |
24 | Sketch.on(s, 'stop', async () => {
25 | let dataUri = await Sketch.save(s)
26 | let link = document.createElement('a')
27 | let { path } = entrypoint
28 | let index = path.lastIndexOf('/')
29 | let [name, ext] = path.slice(index + 1).split('.')
30 | link.href = dataUri
31 | link.download = `${name}.${type}`
32 | link.click()
33 | })
34 |
35 | Sketch.play(s)
36 | },
37 | [sketch, entrypoint]
38 | )
39 |
40 | return (
41 |
42 |
43 | onDownload('png')}>
44 | PNG
45 |
46 | onDownload('svg')}>
47 | SVG
48 |
49 |
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/packages/void/src/config/methods.ts:
--------------------------------------------------------------------------------
1 | import { Config } from '.'
2 | import { Sizes, Size, Orientation, Units } from '..'
3 | import { resolveOrientation } from '../utils'
4 |
5 | /** Resolve the dimensions of a `config`. */
6 | export function dimensions(config: Config): Sizes<2> {
7 | let d = config.dimensions
8 | return d == null
9 | ? [Infinity, Infinity, 'px']
10 | : Size.is(d)
11 | ? Size.dimensions(d)
12 | : d
13 | }
14 |
15 | /** Resolve the margin from a `config`. */
16 | export function margin(config: Config): Sizes<4> {
17 | let { margin } = config
18 |
19 | if (margin == null) {
20 | let [, , du] = dimensions(config)
21 | margin = [0, 0, 0, 0, du]
22 | } else if (margin.length === 2) {
23 | let [a, mu] = margin
24 | margin = [a, a, a, a, mu]
25 | } else if (margin.length === 3) {
26 | let [v, h, mu] = margin
27 | margin = [v, h, v, h, mu]
28 | } else if (margin.length === 4) {
29 | let [t, h, b, mu] = margin
30 | margin = [t, h, b, h, mu]
31 | }
32 |
33 | return margin
34 | }
35 |
36 | /** Resolve the orientation from a `config`. */
37 | export function orientation(config: Config): Orientation | undefined {
38 | let { orientation } = config
39 |
40 | if (orientation == null) {
41 | let [w, h] = dimensions(config)
42 | orientation =
43 | w === Infinity || h === Infinity ? undefined : resolveOrientation(w, h)
44 | }
45 |
46 | return orientation
47 | }
48 |
49 | /** Resolve the precision of a `config`. */
50 | export function precision(config: Config): [number, Units] {
51 | let u = units(config)
52 | let p = u === 'px' ? 1 : 0
53 | return config.precision ?? [p, u]
54 | }
55 |
56 | /** Resolve the units of a `config`. */
57 | export function units(config: Config): Units {
58 | return config.units ?? dimensions(config)[2]
59 | }
60 |
--------------------------------------------------------------------------------
/packages/void/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "void",
3 | "version": "3.0.1",
4 | "description": "A toolkit for making generative art.",
5 | "license": "MIT",
6 | "repository": "git://github.com/ianstormtaylor/void.git",
7 | "type": "module",
8 | "module": "./dist/index.mjs",
9 | "main": "./dist/index.cjs",
10 | "types": "./dist/index.d.ts",
11 | "files": [
12 | "dist"
13 | ],
14 | "engines": {
15 | "node": ">=16",
16 | "typescript": ">=4.9"
17 | },
18 | "scripts": {
19 | "build": "rm -rf ./{dist} && rollup --config ./rollup.config.js",
20 | "coverage": "vitest run --coverage",
21 | "dev": "vitest watch",
22 | "publish": "npm run build && npm run test && npm publish",
23 | "test": "vitest run"
24 | },
25 | "dependencies": {},
26 | "devDependencies": {
27 | "@rollup/plugin-commonjs": "^23.0.3",
28 | "@rollup/plugin-typescript": "^10.0.1",
29 | "@vitest/coverage-c8": "^0.25.3",
30 | "rollup": "^3.5.0",
31 | "vite": "*",
32 | "vitest": "*"
33 | },
34 | "keywords": [
35 | "2d",
36 | "3d",
37 | "animation",
38 | "art",
39 | "axidraw",
40 | "canvas",
41 | "canvas2d",
42 | "coding",
43 | "controls",
44 | "creative",
45 | "dat",
46 | "draw",
47 | "editor",
48 | "export",
49 | "generative",
50 | "gif",
51 | "gui",
52 | "hud",
53 | "input",
54 | "jpg",
55 | "math",
56 | "opengl",
57 | "p5",
58 | "pen",
59 | "physics",
60 | "plotter",
61 | "png",
62 | "processing",
63 | "random",
64 | "seed",
65 | "shape",
66 | "simulate",
67 | "simulation",
68 | "sketch",
69 | "slider",
70 | "studio",
71 | "svg",
72 | "three",
73 | "trait",
74 | "tweakpane",
75 | "ui",
76 | "utils",
77 | "variable",
78 | "vector",
79 | "void",
80 | "webgl"
81 | ]
82 | }
83 |
--------------------------------------------------------------------------------
/apps/electron/electron-builder.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('electron-builder').Configuration} */
2 | module.exports = {
3 | // https://www.electron.build/configuration/configuration
4 | appId: 'com.electron.void',
5 | productName: 'Void',
6 | files: ['./out'],
7 | directories: {
8 | output: './dist',
9 | buildResources: './resources',
10 | },
11 | // Using ASAR seems to break `esbuild` sadly, need to investigate.
12 | asar: false,
13 | // Publishing to GitHub releases for downloading and auto-updating.
14 | publish: {
15 | provider: 'github',
16 | private: true,
17 | },
18 | // Mac-specific config.
19 | mac: {
20 | target: ['dmg', 'zip'].map((target) => ({
21 | target,
22 | arch: ['x64', 'arm64'],
23 | })),
24 | artifactName: '${productName}-macOS-${arch}.${ext}',
25 | category: 'public.app-category.developer-tools',
26 | // These things seem to be required for signing / opening files.
27 | hardenedRuntime: true,
28 | entitlements: './resources/entitlements.mac.plist',
29 | entitlementsInherit: './resources/entitlements.mac.plist',
30 | },
31 | afterSign: 'scripts/notarize.js',
32 | // Windows-specific config.
33 | win: {
34 | target: ['nsis'].map((target) => ({ target, arch: 'x64' })),
35 | artifactName: '${productName}-Windows-Setup-${arch}.${ext}',
36 | },
37 | nsis: {
38 | perMachine: true,
39 | },
40 | // Linux-specific config.
41 | linux: {
42 | target: ['appimage'].map((target) => ({ target, arch: 'x64' })),
43 | artifactName: '${productName}-Linux-${arch}.${ext}',
44 | category: 'Development',
45 | },
46 | // Associated files that will be openable with the app. The `ext` property
47 | // says it accepts arrays, but that seems to fail on Linux builds.
48 | fileAssociations: ['js', 'jsx', 'ts', 'tsx', 'cjs', 'mjs'].map((ext) => ({
49 | ext,
50 | name: 'Sketch File',
51 | role: 'Viewer',
52 | rank: 'Alternate',
53 | icon: './resources/icon.png',
54 | })),
55 | }
56 |
--------------------------------------------------------------------------------
/examples/classics/francois-morellet.js:
--------------------------------------------------------------------------------
1 | import { Void } from 'void'
2 |
3 | // https://www.wikiart.org/en/francois-morellet/tirets-neon-0-90-avec-4-rythmes-interferents-1971
4 | export default function () {
5 | let grid = Void.pick('grid', [3, 5, 7])
6 | let { width, height } = Void.settings({
7 | dimensions: [500, 500, 'px'],
8 | fps: 2,
9 | })
10 |
11 | let bg = Void.layer('background')
12 | bg.fillStyle = '#111111'
13 | bg.fillRect(0, 0, width, height)
14 |
15 | let offs = Void.layer('offs')
16 | offs.lineWidth = 1
17 | offs.strokeStyle = 'rgb(75,50,50)'
18 |
19 | let ons = Void.layer('ons')
20 | ons.lineWidth = 3
21 | ons.strokeStyle = 'rgb(255,50,50)'
22 | ons.lineCap = 'round'
23 | ons.shadowColor = 'rgb(255,0,0)'
24 | ons.shadowBlur = 10
25 |
26 | let padding = width * 0.025
27 | let cell = (width - padding * 2) / grid
28 | let edges = [0, 1, 2, 3]
29 | let boxes = []
30 |
31 | for (let col = 0; col < grid; col++) {
32 | for (let row = 0; row < grid; row++) {
33 | let x = padding + col * cell
34 | let y = padding + row * cell
35 | boxes.push([
36 | [x, y],
37 | [x, y + cell],
38 | [x + cell, y + cell],
39 | [x + cell, y],
40 | ])
41 | }
42 | }
43 |
44 | Void.draw(() => {
45 | ons.clearRect(0, 0, width, height)
46 | offs.clearRect(0, 0, width, height)
47 |
48 | let highlights = edges
49 | .slice()
50 | .sort(() => 0.5 - Void.random())
51 | .slice(0, 2)
52 |
53 | for (let [i, box] of boxes.entries()) {
54 | let h = highlights.at(i % highlights.length)
55 |
56 | for (let j = 1; j < box.length; j++) {
57 | let [ax, ay] = box[j - 1]
58 | let [bx, by] = box[j]
59 | offs.beginPath()
60 | offs.moveTo(ax, ay)
61 | offs.lineTo(bx, by)
62 | offs.stroke()
63 |
64 | if (h === j) {
65 | ons.beginPath()
66 | ons.moveTo(ax, ay)
67 | ons.lineTo(bx, by)
68 | ons.stroke()
69 | }
70 | }
71 | }
72 | })
73 | }
74 |
--------------------------------------------------------------------------------
/examples/basics/pointer.js:
--------------------------------------------------------------------------------
1 | import { Void } from 'void'
2 | import * as scales from 'd3-scale-chromatic'
3 |
4 | // https://p5js.org/examples/interaction-follow-3.html
5 | // https://www.youtube.com/watch?v=sEKNoWyKUA0
6 | export default function () {
7 | // Get the dimensions of the fullscreen sketch.
8 | let { width, height } = Void.settings()
9 |
10 | // Define some traits that control our sketch's behavior.
11 | let segments = Void.int('segments', 5, 25)
12 | let length = Void.int('length', 15, 45, 5)
13 | let palette = Void.pick('palette', {
14 | skeletal: () => 'rgba(255,255,255,0.5)',
15 | plasma: scales.interpolatePlasma,
16 | piyg: scales.interpolatePiYG,
17 | cool: scales.interpolateCool,
18 | turbo: scales.interpolateTurbo,
19 | })
20 |
21 | // Get a reference to the pointer (eg. mouse or finger) as it moves.
22 | let pointer = Void.pointer()
23 |
24 | // Create a new layer to draw on.
25 | let ctx = Void.layer()
26 | ctx.lineWidth = 9
27 | ctx.lineCap = 'round'
28 |
29 | // Define a few variables to use for the sketch.
30 | let target = [width / 2, height / 2]
31 | let points = Array.from({ length: segments }, () => {
32 | return [Void.random(0, width), Void.random(0, height)]
33 | })
34 |
35 | // On every frame...
36 | Void.draw(() => {
37 | // Reset the canvas to be all black.
38 | ctx.fillRect(0, 0, width, height)
39 |
40 | // Use the latest pointer position as the target, or the previous one.
41 | target = pointer.position ?? target
42 |
43 | // Draw each segment of the chain, keeping the entire chain connected.
44 | for (let i = 0; i < segments; i++) {
45 | // Chain each new point to the previous point.
46 | let point = points[i]
47 | let prev = i === 0 ? target : points[i - 1]
48 | let [px, py] = prev
49 | let [x, y] = point
50 | let angle = Math.atan2(py - y, px - x)
51 | let nx = (point[0] = px - Math.cos(angle) * length)
52 | let ny = (point[1] = py - Math.sin(angle) * length)
53 |
54 | // Draw the segment.
55 | ctx.save()
56 | ctx.strokeStyle = palette(1 - i / segments)
57 | ctx.translate(nx, ny)
58 | ctx.rotate(angle)
59 | ctx.beginPath()
60 | ctx.moveTo(0, 0)
61 | ctx.lineTo(length, 0)
62 | ctx.stroke()
63 | ctx.restore()
64 | }
65 | })
66 | }
67 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/panels/layout-panel.tsx:
--------------------------------------------------------------------------------
1 | import { DimensionsField } from '../fields/dimensions-field'
2 | import { MarginsField } from '../fields/margins-field'
3 | import { MdCrop169, MdCropSquare } from 'react-icons/md'
4 | import { useStore } from '../../contexts/store'
5 | import { useTab } from '../../contexts/tab'
6 | import { Config } from 'void'
7 | import { IconButton } from '../ui/icon-button'
8 | import { SidebarPanel } from '../ui/sidebar-panel'
9 | import { useSketch } from '../../contexts/sketch'
10 |
11 | export let LayoutPanel = () => {
12 | let [, setConfig] = useStore()
13 | let [tab] = useTab()
14 | let sketch = useSketch()
15 | let { config } = sketch
16 | let orientation = Config.orientation(config)
17 | let [width, height, units] = Config.dimensions(config)
18 | return (
19 | Fullscreen
24 | ) : (
25 |
26 | {width} × {height} {units}
27 |
28 | )
29 | }
30 | buttons={
31 | <>
32 | {
34 | setConfig((c) => {
35 | let t = c.tabs[tab.id]
36 | t.config.orientation = 'portrait'
37 | })
38 | }}
39 | active={orientation === 'portrait'}
40 | >
41 |
42 |
43 | {
46 | setConfig((c) => {
47 | let t = c.tabs[tab.id]
48 | t.config.orientation = 'landscape'
49 | })
50 | }}
51 | >
52 |
53 |
54 | {
57 | setConfig((c) => {
58 | let t = c.tabs[tab.id]
59 | t.config.orientation = 'square'
60 | })
61 | }}
62 | >
63 |
64 |
65 | >
66 | }
67 | >
68 |
69 |
70 |
71 | )
72 | }
73 |
--------------------------------------------------------------------------------
/apps/electron/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@apps/electron",
3 | "productName": "Void",
4 | "private": true,
5 | "version": "3.0.1",
6 | "description": "A visual studio for making generative art.",
7 | "author": "Ian Storm Taylor",
8 | "license": "MIT",
9 | "repository": "git://github.com/ianstormtaylor/void.git",
10 | "main": "./out/main/index.js",
11 | "scripts": {
12 | "build": "electron-vite build && dotenv -- electron-builder --config ./electron-builder.config.js --mac --win --linux",
13 | "build:preview": "electron-vite build --mode preview && dotenv -- electron-builder --mac -c.mac.identity=null",
14 | "debug": "./dist/mac/Void.app/Contents/MacOS/Void --remote-debugging-port=8315 --enable-logging",
15 | "debug:release": "/Applications/Void.app/Contents/MacOS/Void --remote-debugging-port=8315 --enable-logging",
16 | "dev": "electron-vite dev",
17 | "dev:preview": "electron-vite preview --mode preview",
18 | "logs": "code ~/Library/Logs/Void/main.log",
19 | "publish": "npm run build -- --publish always"
20 | },
21 | "dependencies": {
22 | "autoprefixer": "^10.4.7",
23 | "dotenv-cli": "^6.0.0",
24 | "electron-devtools-vendor": "^1.1.0",
25 | "electron-notarize": "^1.2.1",
26 | "electron-vite": "^1.0.13",
27 | "esbuild-wasm": "^0.15.16",
28 | "postcss": "^8.4.13",
29 | "tailwindcss": "^3.0.24"
30 | },
31 | "devDependencies": {
32 | "@headlessui/react": "^1.6.5",
33 | "@heroicons/react": "^1.0.6",
34 | "@types/lodash": "^4.14.182",
35 | "@types/react": "^18.0.15",
36 | "@types/react-dom": "^18.0.6",
37 | "@vitejs/plugin-react": "^1.3.2",
38 | "change-case": "^4.1.2",
39 | "electron": "19.0.3",
40 | "electron-builder": "^23.0.3",
41 | "electron-log": "^4.4.8",
42 | "electron-store": "^8.0.1",
43 | "electron-unhandled": "^4.0.1",
44 | "fix-path": "^4.0.0",
45 | "immer": "^9.0.15",
46 | "jspdf": "^2.5.1",
47 | "lodash": "^4.17.21",
48 | "platform": "1.3.6",
49 | "plur": "^5.1.0",
50 | "react": "^18.1.0",
51 | "react-dom": "^18.1.0",
52 | "react-icons": "^4.4.0",
53 | "react-merge-refs": "^2.0.1",
54 | "react-router-dom": "^6.4.0",
55 | "react-use": "^17.4.0",
56 | "source-map": "^0.5.7",
57 | "stacktrace-parser": "0.1.10",
58 | "svg2pdf.js": "^2.2.0",
59 | "svgcanvas": "^2.3.0",
60 | "tempy": "^3.0.0",
61 | "update-electron-app": "^2.0.1",
62 | "use-immer": "^0.7.0",
63 | "vite": "*",
64 | "void": "*"
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/apps/electron/src/shared/store/base.ts:
--------------------------------------------------------------------------------
1 | import produce, { Patch, enablePatches, applyPatches } from 'immer'
2 |
3 | enablePatches()
4 |
5 | /** The IPC channel used when new renderer processes connect. */
6 | export let CONNECT_CHANNEL = '__SHARED_STATE_CONNECT__'
7 |
8 | /** The IPC channel used when new changes are applied to the store. */
9 | export let CHANGE_CHANNEL = '__SHARED_STATE_CHANGE__'
10 |
11 | /** A listener function that gets called when the store's state changes. */
12 | export type Listener> = (
13 | state: T,
14 | prevState: T,
15 | patches: Patch[]
16 | ) => void
17 |
18 | /** A store that is synced across Electron processes. */
19 | export type Store> = {
20 | /** Get the store's current state. */
21 | get: () => T
22 | /** Change the store's immutable state using an Immer `recipe` function. */
23 | change: (recipe: (draft: T) => T | void) => void
24 | /** Apply a set of patches to the store's immutable state. */
25 | patch: (patches: Patch[]) => void
26 | /** Subscribe to the store's state when changes are applied. */
27 | subscribe: (listener: Listener) => () => void
28 | }
29 |
30 | /** Create a store with an `initialState` and process-specific `send` logic. */
31 | export function createStore>(
32 | initialState: T,
33 | send: (patches: Patch[], senderId?: number) => void
34 | ) {
35 | let state = initialState
36 | let listeners: Listener[] = []
37 | let isUpdating = false
38 | let emit = (prevState: T, patches: Patch[]) => {
39 | for (let listener of listeners) {
40 | listener(state, prevState, patches)
41 | }
42 | }
43 |
44 | let store: Store = {
45 | get() {
46 | return state
47 | },
48 |
49 | change(recipe) {
50 | let patches: Patch[] = []
51 | let prevState = state
52 | isUpdating = true
53 | state = produce(state, recipe, (p) => (patches = p))
54 | isUpdating = false
55 | send(patches)
56 | emit(prevState, patches)
57 | },
58 |
59 | patch(patches) {
60 | if (patches.length === 0) return
61 | let prevState = state
62 | state = applyPatches(state, patches)
63 | send(patches)
64 | emit(prevState, patches)
65 | },
66 |
67 | subscribe(listener) {
68 | if (isUpdating) throw new Error('Cannot subscribe() while changing!')
69 | listeners.push(listener)
70 | listener(state, state, [])
71 |
72 | return () => {
73 | if (isUpdating) throw new Error('Cannot unsubscribe() while changing!')
74 | let index = listeners.indexOf(listener)
75 | listeners.splice(index, 1)
76 | }
77 | },
78 | }
79 |
80 | return store
81 | }
82 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/fields/trait-field.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react'
2 | import { NumberField } from './number-field'
3 | import { capitalCase } from 'change-case'
4 | import { BooleanField } from './boolean-field'
5 | import { useTab } from '../../contexts/tab'
6 | import { IconButton } from '../ui/icon-button'
7 | import { MdOutlineLock, MdOutlineLockOpen } from 'react-icons/md'
8 | import { EnumField } from './enum-field'
9 | import { useSketch } from '../../contexts/sketch'
10 |
11 | export let TraitField = (props: { name: string }) => {
12 | let sketch = useSketch()
13 | let { name } = props
14 | let value = sketch.traits[name]
15 | let schema = sketch.schemas![name]
16 | let [tab, changeTab] = useTab()
17 | let label = capitalCase(name)
18 | let overridden = tab.traits != null && name in tab.traits
19 | let valueClassName = overridden ? 'font-bold' : ''
20 | let field: React.ReactNode
21 |
22 | let onChange = useCallback(
23 | (v: any) => {
24 | changeTab((t) => (t.traits[name] = v))
25 | },
26 | [changeTab]
27 | )
28 |
29 | if (schema.type === 'int' || schema.type === 'float') {
30 | field = (
31 |
40 | )
41 | } else if (schema.type === 'boolean') {
42 | field = (
43 |
49 | )
50 | } else if (schema.type === 'pick') {
51 | field = (
52 |
59 | )
60 | } else {
61 | let n: never = schema
62 | throw new Error(`Unhandled schema: "${JSON.stringify(n)}"`)
63 | }
64 |
65 | return (
66 |
67 |
{field}
68 |
69 | {
73 | changeTab((t) => {
74 | if (name in t.traits) {
75 | delete t.traits[name]
76 | } else {
77 | t.traits[name] = value
78 | }
79 | })
80 | }}
81 | >
82 | {overridden ? : }
83 |
84 |
85 |
86 | )
87 | }
88 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/editor-toolbar.tsx:
--------------------------------------------------------------------------------
1 | import { MdBuild, MdFavoriteBorder, MdGridView } from 'react-icons/md'
2 | import { useTab } from '../contexts/tab'
3 | import { useEntrypoint } from '../contexts/entrypoint'
4 |
5 | export let EditorToolbar = () => {
6 | let [tab, changeTab] = useTab()
7 | let [entrypoint] = useEntrypoint()
8 | let path = `.../${entrypoint.path.split('/').slice(-3).join('/')}`
9 | return (
10 |
11 |
12 |
26 |
27 |
{path}
28 |
29 |
52 | {/*
62 | */}
72 |
73 |
74 | )
75 | }
76 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/index.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client'
2 | import { useEffect, useState } from 'react'
3 | import { Editor } from './components/editor'
4 | import { HashRouter, Route, Routes, useParams } from 'react-router-dom'
5 | import { useStore } from './contexts/store'
6 | import { TabContext } from './contexts/tab'
7 | import { EntrypointContext } from './contexts/entrypoint'
8 | import { WindowContext } from './contexts/window'
9 | import { Window } from './components/window'
10 |
11 | let App = () => {
12 | let [dragging, setDragging] = useState(false)
13 |
14 | useEffect(() => {
15 | const onDrop = (e: DragEvent) => {
16 | e.preventDefault()
17 | setDragging(false)
18 | const files = e.dataTransfer?.files ?? []
19 | const paths = Array.from(files).map((f) => f.path)
20 | window.electron.openFiles(paths)
21 | }
22 | const onDragOver = (e: DragEvent) => {
23 | if (e.dataTransfer?.files.length) {
24 | e.preventDefault()
25 | setDragging(true)
26 | }
27 | }
28 | const onDragLeave = (e: DragEvent) => {
29 | e.preventDefault()
30 | setDragging(false)
31 | }
32 | document.body.addEventListener('drop', onDrop)
33 | document.body.addEventListener('dragover', onDragOver)
34 | document.body.addEventListener('dragleave', onDragLeave)
35 | return () => {
36 | document.body.removeEventListener('drop', onDrop)
37 | document.body.removeEventListener('dragover', onDragOver)
38 | document.body.removeEventListener('dragleave', onDragLeave)
39 | }
40 | }, [])
41 |
42 | return (
43 |
44 |
45 |
46 | } />
47 | } />
48 |
49 |
50 | {dragging && (
51 |
52 | )}
53 |
54 | )
55 | }
56 |
57 | let WindowPage = () => {
58 | let { id } = useParams()
59 | let [config] = useStore()
60 | let window = config.windows[id!]
61 | return (
62 | window && (
63 |
64 |
65 |
66 | )
67 | )
68 | }
69 |
70 | let TabPage = () => {
71 | let { id } = useParams()
72 | let [store] = useStore()
73 | let tab = store.tabs[id!]
74 | let entrypoint = store.entrypoints[tab?.entrypointId]
75 | return (
76 |
77 |
78 |
79 |
80 |
81 | )
82 | }
83 |
84 | let root = createRoot(document.getElementById('main')!)
85 | root.render()
86 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/window-tabs.tsx:
--------------------------------------------------------------------------------
1 | import { useStore } from '../contexts/store'
2 | import {
3 | EntrypointState,
4 | TabState,
5 | WindowState,
6 | } from '../../shared/store-state'
7 | import { MdAdd, MdClose } from 'react-icons/md'
8 |
9 | export let WindowTabs = (props: { window: WindowState }) => {
10 | let { window } = props
11 | let [store] = useStore()
12 | let tabs = window.tabIds.map((tid) => store.tabs[tid])
13 | return (
14 |
15 |
16 | {Object.values(tabs).map((tab) => (
17 |
23 | ))}
24 |
25 |
26 |
37 |
38 |
39 | )
40 | }
41 |
42 | export let Tab = (props: {
43 | tab: TabState
44 | entrypoint: EntrypointState
45 | active: boolean
46 | }) => {
47 | let { tab, active, entrypoint } = props
48 | let { id } = tab
49 | let { path } = entrypoint
50 | let index = path.lastIndexOf('/')
51 | let file = path.slice(index + 1)
52 | return (
53 | {
63 | if (!active) electron.activateTab(id)
64 | }}
65 | >
66 |
67 | {file}
68 | {' '}
69 |
86 |
87 | )
88 | }
89 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/panels/layers-panel.tsx:
--------------------------------------------------------------------------------
1 | import { capitalCase } from 'change-case'
2 | import {
3 | MdClear,
4 | MdOutlineLayers,
5 | MdOutlineVisibility,
6 | MdOutlineVisibilityOff,
7 | } from 'react-icons/md'
8 | import { useSketch } from '../../contexts/sketch'
9 | import { useTab } from '../../contexts/tab'
10 | import { IconButton } from '../ui/icon-button'
11 | import { SidebarPanel } from '../ui/sidebar-panel'
12 | import plur from 'plur'
13 |
14 | export let LayersPanel = () => {
15 | let [tab, changeTab] = useTab()
16 | let sketch = useSketch()
17 | let count = Object.keys(sketch.layers).length
18 | let sketchHas = count > 0
19 | let tabHas = Object.keys(tab.layers).length > 0
20 | return (
21 | 1}
24 | summary={`${count} ${plur('layer', count)}`}
25 | buttons={
26 | <>
27 | {
31 | changeTab((t) => {
32 | t.layers = {}
33 | })
34 | }}
35 | >
36 |
37 |
38 | >
39 | }
40 | >
41 | {sketchHas ? (
42 | Object.keys(sketch.layers)
43 | .slice()
44 | .reverse()
45 | .map((key) => {
46 | let hidden = tab.layers?.[key]?.hidden
47 | let label = capitalCase(key)
48 | return (
49 |
53 |
60 |
61 |
62 |
68 | {label}
69 |
70 |
71 | {
75 | changeTab((t) => {
76 | if (key in t.layers) {
77 | delete t.layers[key]
78 | } else {
79 | t.layers[key] = { hidden: true }
80 | }
81 | })
82 | }}
83 | >
84 | {hidden ? (
85 |
86 | ) : (
87 |
88 | )}
89 |
90 |
91 |
92 | )
93 | })
94 | ) : (
95 | No layers were defined.
96 | )}
97 |
98 | )
99 | }
100 |
--------------------------------------------------------------------------------
/packages/void/src/sketch/index.ts:
--------------------------------------------------------------------------------
1 | import { Config, Units } from '..'
2 |
3 | /** The sketch-related methods. */
4 | export * as Sketch from './methods'
5 |
6 | /** An object representing a Void sketch's state. */
7 | export type Sketch = {
8 | config: Config
9 | construct: () => void
10 | draw?: () => void
11 | container: HTMLElement
12 | el: HTMLElement
13 | frame?: Frame
14 | handlers?: Handlers
15 | hash: string
16 | keyboard?: Keyboard
17 | layers: Record
18 | pointer?: Pointer
19 | output: Output
20 | prng?: () => number
21 | raf?: number
22 | schemas?: Record
23 | settings: Settings
24 | status?: 'playing' | 'paused' | 'stopped'
25 | traits: Record
26 | }
27 |
28 | /** A frame of a sketch's draw function. */
29 | export type Frame = {
30 | count: number
31 | time: number
32 | rate: number
33 | }
34 |
35 | /** The event handlers of a sketch. */
36 | export type Handlers = {
37 | construct: Array<() => void>
38 | draw: Array<() => void>
39 | error: Array<(error: Error) => void>
40 | play: Array<() => void>
41 | pause: Array<() => void>
42 | stop: Array<() => void>
43 | }
44 |
45 | /** Data about the keyboard while a sketch is running. */
46 | export type Keyboard = {
47 | code: string | null
48 | codes: Record
49 | key: string | null
50 | keys: Record
51 | }
52 |
53 | /** A layer of the sketch. */
54 | export type Layer = {
55 | hidden: boolean
56 | export?: () => string
57 | }
58 |
59 | /** Output settings when exporting a sketch. */
60 | export type Output = OutputPng | OutputJpg | OutputWebp | OutputSvg | OutputPdf
61 |
62 | /** PNG output settings. */
63 | export type OutputPng = {
64 | type: 'png'
65 | }
66 |
67 | /** JPG output settings. */
68 | export type OutputJpg = {
69 | type: 'jpg'
70 | quality: number
71 | }
72 |
73 | export type OutputWebp = {
74 | type: 'webp'
75 | quality?: number
76 | }
77 |
78 | export type OutputSvg = {
79 | type: 'svg'
80 | }
81 |
82 | export type OutputPdf = {
83 | type: 'pdf'
84 | rasterize?: boolean
85 | }
86 |
87 | /** Data about the mouse while a sketch is running. */
88 | export type Pointer = {
89 | type: 'mouse' | 'pen' | 'touch' | null
90 | x: number | null
91 | y: number | null
92 | position: [number, number] | null
93 | button: number | null
94 | buttons: Record
95 | }
96 |
97 | /** A schema which defines the values of a trait. */
98 | export type Schema = SchemaBool | SchemaInt | SchemaFloat | SchemaPick
99 |
100 | /** A schema for boolean traits. */
101 | export type SchemaBool = {
102 | type: 'boolean'
103 | probability: number
104 | initial?: boolean
105 | }
106 |
107 | /** A schema for integer traits. */
108 | export type SchemaInt = {
109 | type: 'int'
110 | min: number
111 | max: number
112 | step: number
113 | initial?: number
114 | }
115 |
116 | /** A schema for floating point number traits. */
117 | export type SchemaFloat = {
118 | type: 'float'
119 | min: number
120 | max: number
121 | step?: number
122 | initial?: number
123 | }
124 |
125 | /** A schema for enum traits. */
126 | export type SchemaPick = {
127 | type: 'pick'
128 | names: string[]
129 | weights: number[]
130 | initial?: string
131 | }
132 |
133 | /** The userland-exposed settings of a sketch. */
134 | export type Settings = {
135 | dpi: number
136 | fps: number
137 | frames: number
138 | height: number
139 | margin: [number, number, number, number]
140 | precision: number | null
141 | units: Units
142 | width: number
143 | }
144 |
--------------------------------------------------------------------------------
/apps/electron/src/main/menus.ts:
--------------------------------------------------------------------------------
1 | import { Menu, app } from 'electron'
2 | import { IS_DEV } from './env'
3 | import { main } from './classes/main'
4 | import { Tab } from './classes/tab'
5 | import { Window } from './classes/window'
6 |
7 | /** A dock menu. */
8 | export let dockMenu = Menu.buildFromTemplate([
9 | {
10 | label: 'New Window',
11 | accelerator: 'CmdOrCtrl+N',
12 | click() {
13 | let window = Window.create()
14 | window.show()
15 | },
16 | },
17 | {
18 | label: 'Open Sketch…',
19 | click() {
20 | main.open()
21 | },
22 | },
23 | { role: 'recentDocuments', submenu: [{ role: 'clearRecentDocuments' }] },
24 | ])
25 |
26 | /** The app's top-level menu. */
27 | export let appMenu = Menu.buildFromTemplate([
28 | {
29 | label: app.name,
30 | submenu: [
31 | { role: 'about' },
32 | { type: 'separator' },
33 | { role: 'services' },
34 | { type: 'separator' },
35 | { role: 'hide' },
36 | { role: 'hideOthers' },
37 | { role: 'unhide' },
38 | { type: 'separator' },
39 | { role: 'quit' },
40 | ],
41 | },
42 | {
43 | label: 'File',
44 | submenu: [
45 | {
46 | label: 'New Window',
47 | accelerator: 'CmdOrCtrl+N',
48 | click() {
49 | let window = Window.create()
50 | window.show()
51 | },
52 | },
53 | {
54 | label: 'Open Sketch…',
55 | accelerator: 'CmdOrCtrl+O',
56 | click() {
57 | main.open()
58 | },
59 | },
60 | { role: 'recentDocuments', submenu: [{ role: 'clearRecentDocuments' }] },
61 | { type: 'separator' },
62 | {
63 | label: 'Close Sketch',
64 | accelerator: 'CmdOrCtrl+W',
65 | click() {
66 | let tab = Tab.byActive()
67 | let window = Window.byActive()
68 | if (window && tab) {
69 | window.closeTab(tab.id)
70 | } else if (window) {
71 | window.close()
72 | }
73 | },
74 | },
75 | ],
76 | },
77 | {
78 | label: 'View',
79 | submenu: [
80 | {
81 | label: 'Reload',
82 | accelerator: 'CmdOrCtrl+R',
83 | click() {
84 | let tab = Tab.byActive()
85 | if (tab) tab.reload()
86 | },
87 | },
88 | { type: 'separator' },
89 | { role: 'togglefullscreen' },
90 | { type: 'separator' },
91 | {
92 | label: 'Show Developer Tools',
93 | accelerator: 'CmdOrCtrl+Alt+I',
94 | click() {
95 | let tab = Tab.byActive()
96 | if (tab) tab.inspect()
97 | },
98 | },
99 | ],
100 | },
101 | {
102 | label: 'Development',
103 | visible: IS_DEV,
104 | submenu: [
105 | {
106 | label: 'Reload Window',
107 | click() {
108 | let window = Window.byFocused()
109 | if (window) window.reload()
110 | },
111 | },
112 | {
113 | label: 'Show Window Developer Tools',
114 | click() {
115 | let window = Window.byFocused()
116 | if (window) window.inspect()
117 | },
118 | },
119 | { type: 'separator' },
120 | {
121 | label: 'Log Storage',
122 | click() {
123 | console.log('')
124 | console.log('Store:', JSON.stringify(main.store, null, 2))
125 | },
126 | },
127 | {
128 | label: 'Clear Storage and Quit',
129 | click() {
130 | main.clear()
131 | main.quit()
132 | },
133 | },
134 | ],
135 | },
136 | ])
137 |
--------------------------------------------------------------------------------
/packages/void/src/size/index.ts:
--------------------------------------------------------------------------------
1 | /** Export the size-related methods namespace. */
2 | export * as Size from './methods'
3 |
4 | /** A recognized size keyword. */
5 | export type Size = keyof typeof SIZES
6 |
7 | /** Sizes of length `N`, with specific units. */
8 | export type Sizes = T extends {
9 | length: N
10 | }
11 | ? [...T, Units]
12 | : Sizes
13 |
14 | /** The unit of measurement for the canvas. */
15 | export type Units = 'm' | 'cm' | 'mm' | 'in' | 'ft' | 'yd' | 'pt' | 'pc' | 'px'
16 |
17 | /** Types of unit systems. */
18 | export type UnitsSystem = 'metric' | 'imperial'
19 |
20 | /** A dictionary of recognized paper sizes. */
21 | export const SIZES = {
22 | // ISO A
23 | // https://en.wikipedia.org/wiki/ISO_216
24 | A0: [841, 1189, 'mm'],
25 | A1: [594, 841, 'mm'],
26 | A2: [420, 594, 'mm'],
27 | A3: [297, 420, 'mm'],
28 | A4: [210, 297, 'mm'],
29 | A5: [148, 210, 'mm'],
30 | A6: [105, 148, 'mm'],
31 | A7: [74, 105, 'mm'],
32 | A8: [52, 74, 'mm'],
33 | A9: [37, 52, 'mm'],
34 | A10: [26, 37, 'mm'],
35 | // ISO B
36 | // https://en.wikipedia.org/wiki/ISO_216
37 | B0: [1000, 1414, 'mm'],
38 | B1: [707, 1000, 'mm'],
39 | B2: [500, 707, 'mm'],
40 | B3: [353, 500, 'mm'],
41 | B4: [250, 353, 'mm'],
42 | B5: [176, 250, 'mm'],
43 | B6: [125, 176, 'mm'],
44 | B7: [88, 125, 'mm'],
45 | B8: [62, 88, 'mm'],
46 | B9: [44, 62, 'mm'],
47 | B10: [31, 44, 'mm'],
48 | // ISO C
49 | // https://en.wikipedia.org/wiki/ISO_216
50 | C0: [917, 1297, 'mm'],
51 | C1: [648, 917, 'mm'],
52 | C2: [458, 648, 'mm'],
53 | C3: [324, 458, 'mm'],
54 | C4: [229, 324, 'mm'],
55 | C5: [162, 229, 'mm'],
56 | C6: [114, 162, 'mm'],
57 | C7: [81, 114, 'mm'],
58 | C8: [57, 81, 'mm'],
59 | C9: [40, 57, 'mm'],
60 | C10: [28, 40, 'mm'],
61 | // US
62 | // https://en.wikipedia.org/wiki/Paper_size
63 | // https://papersizes.io
64 | // https://www.princexml.com/doc/page-size-keywords/
65 | Letter: [8.5, 11, 'in'],
66 | Legal: [8.5, 14, 'in'],
67 | Tabloid: [11, 17, 'in'],
68 | // JIS B
69 | // https://en.wikipedia.org/wiki/Paper_size
70 | 'JIS B0': [1030, 1456, 'mm'],
71 | 'JIS B1': [728, 1030, 'mm'],
72 | 'JIS B2': [515, 728, 'mm'],
73 | 'JIS B3': [364, 515, 'mm'],
74 | 'JIS B4': [257, 364, 'mm'],
75 | 'JIS B5': [182, 257, 'mm'],
76 | 'JIS B6': [128, 182, 'mm'],
77 | 'JIS B7': [91, 128, 'mm'],
78 | 'JIS B8': [64, 91, 'mm'],
79 | 'JIS B9': [45, 64, 'mm'],
80 | 'JIS B10': [32, 45, 'mm'],
81 | // ANSI
82 | // https://en.wikipedia.org/wiki/Paper_size
83 | 'ANSI A': [8.5, 11, 'in'],
84 | 'ANSI B': [11, 17, 'in'],
85 | 'ANSI C': [17, 22, 'in'],
86 | 'ANSI D': [22, 34, 'in'],
87 | 'ANSI E': [34, 44, 'in'],
88 | // ARCH
89 | // https://en.wikipedia.org/wiki/Paper_size
90 | 'Arch A': [9, 12, 'in'],
91 | 'Arch B': [12, 18, 'in'],
92 | 'Arch C': [18, 24, 'in'],
93 | 'Arch D': [24, 36, 'in'],
94 | 'Arch E': [36, 48, 'in'],
95 | // Photographic
96 | // https://en.wikipedia.org/wiki/Photo_print_sizes
97 | '4R': [4, 6, 'in'],
98 | '5R': [5, 7, 'in'],
99 | '6R': [6, 8, 'in'],
100 | '8R': [8, 10, 'in'],
101 | '10R': [10, 12, 'in'],
102 | '11R': [11, 14, 'in'],
103 | '12R': [12, 15, 'in'],
104 | '14R': [14, 17, 'in'],
105 | '16R': [16, 20, 'in'],
106 | '20R': [20, 24, 'in'],
107 | // 16:9 (UHD)
108 | // https://en.wikipedia.org/wiki/Display_resolution
109 | // https://support.google.com/youtube/answer/6375112
110 | '240p': [426, 240, 'px'],
111 | '360p': [640, 360, 'px'],
112 | '480p': [854, 480, 'px'],
113 | '720p': [1280, 720, 'px'],
114 | '1080p': [1920, 1080, 'px'],
115 | '1440p': [2560, 1440, 'px'],
116 | '2160p': [3840, 2160, 'px'],
117 | '4320p': [7680, 4320, 'px'],
118 | } as const
119 |
--------------------------------------------------------------------------------
/packages/void/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { Orientation, Units, UnitsSystem } from '.'
2 |
3 | /** The SVG namespace string. */
4 | export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
5 |
6 | /** The prefix for Base64-encoded SVG data URIs. */
7 | export const SVG_DATA_URI_PREFIX = 'data:image/svg+xml;base64,'
8 |
9 | /** The fixed DPI that CSS uses when displaying real-world units. */
10 | export const CSS_DPI = 96
11 |
12 | /** Convert an SVG string to a data URI. */
13 | export function svgStringToDataUri(svg: string): string {
14 | const base64 = btoa(svg)
15 | const uri = `${SVG_DATA_URI_PREFIX}${base64}`
16 | return uri
17 | }
18 |
19 | /** Convert an SVG data URI to an SVG string. */
20 | export function svgDataUriToString(uri: string): string {
21 | const base64 = uri.replace(SVG_DATA_URI_PREFIX, '')
22 | const string = atob(base64)
23 | return string
24 | }
25 |
26 | /** Convert an SVG string to an SVG element. */
27 | export function svgStringToElement(string: string): SVGSVGElement {
28 | const div = document.createElement('div')
29 | div.innerHTML = string
30 | const el = div.firstChild as SVGSVGElement
31 | return el
32 | }
33 |
34 | /** Convert an SVG element to an SVG string. */
35 | export function svgElementToString(el: SVGSVGElement): string {
36 | const div = document.createElement('div')
37 | div.appendChild(el)
38 | const string = div.innerHTML
39 | return string
40 | }
41 |
42 | /** Apply an `orientation` to a `width` and `height`. */
43 | export function applyOrientation(
44 | width: number,
45 | height: number,
46 | orientation: Orientation
47 | ): [number, number] {
48 | if (orientation === 'square' && width != height) {
49 | width = height = Math.min(width, height)
50 | } else if (orientation === 'landscape' && width < height) {
51 | ;[width, height] = [height, width]
52 | } else if (orientation === 'portrait' && height < width) {
53 | ;[width, height] = [height, width]
54 | }
55 | return [width, height]
56 | }
57 |
58 | /** Resolve the orientation of a `width` and `height`. */
59 | export function resolveOrientation(width: number, height: number): Orientation {
60 | return width === height ? 'square' : width < height ? 'portrait' : 'landscape'
61 | }
62 |
63 | /** A pseudo-random number generator using the SFC32 algorithm. */
64 | export function Sfc32(
65 | a: number,
66 | b: number,
67 | c: number,
68 | d: number
69 | ): () => number {
70 | return () => {
71 | a |= 0
72 | b |= 0
73 | c |= 0
74 | d |= 0
75 | let t = (((a + b) | 0) + d) | 0
76 | d = (d + 1) | 0
77 | a = b ^ (b >>> 9)
78 | b = (c + (c << 3)) | 0
79 | c = (c << 21) | (c >>> 11)
80 | c = (c + t) | 0
81 | return t >>> 0
82 | }
83 | }
84 |
85 | /** The number of inches in a meter. */
86 | const M_PER_INCH = 0.0254
87 |
88 | /** The number of meters in an inch. */
89 | const INCH_PER_M = 1 / M_PER_INCH
90 |
91 | /** Conversions for units within their respective system. */
92 | const CONVERSIONS: Record, [UnitsSystem, number]> = {
93 | m: ['metric', 1],
94 | cm: ['metric', 1 / 100],
95 | mm: ['metric', 1 / 1000],
96 | in: ['imperial', 1],
97 | ft: ['imperial', 12],
98 | yd: ['imperial', 36],
99 | pc: ['imperial', 1 / 6],
100 | pt: ['imperial', 1 / 72],
101 | }
102 |
103 | /** Convert a `value` from one unit to another. */
104 | export function convertUnits(
105 | value: number,
106 | from: Units,
107 | to: Units,
108 | options: { dpi?: number; precision?: number } = {}
109 | ): number {
110 | if (from === to) return value
111 | const { dpi = CSS_DPI, precision } = options
112 |
113 | // Swap pixels for inches using the dynamic `dpi`.
114 | let factor = 1
115 | if (from === 'px') (factor /= dpi), (from = 'in')
116 | if (to === 'px') (factor *= dpi), (to = 'in')
117 |
118 | // Swap systems if `from` and `to` aren't using the same one.
119 | const [inSystem, inFactor] = CONVERSIONS[from]
120 | const [outSystem, outFactor] = CONVERSIONS[to]
121 | factor *= inFactor
122 | factor /= outFactor
123 | if (inSystem !== outSystem) {
124 | factor *= inSystem === 'metric' ? INCH_PER_M : M_PER_INCH
125 | }
126 |
127 | // Calculate the result and optionally truncate to a fixed number of digits.
128 | let result = value * factor
129 | if (precision != null && precision !== 0) {
130 | const p = 1 / precision
131 | result = Math.trunc(result * p) / p
132 | }
133 |
134 | return result
135 | }
136 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/editor.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useLayoutEffect, useRef, useState } from 'react'
2 | import { EditorSidebar } from './editor-sidebar'
3 | import { EditorToolbar } from './editor-toolbar'
4 | import { useTab } from '../contexts/tab'
5 | import { Sketch } from 'void'
6 | import { SketchContext } from '../contexts/sketch'
7 | import { EditorConsole } from './editor-console'
8 | import { zoomOut } from '../../shared/zoom'
9 | import { cloneDeep } from 'lodash'
10 | import { useWindowSize } from 'react-use'
11 | import { hashInt, hashSeed } from '../utils'
12 | import { useEntrypoint } from '../contexts/entrypoint'
13 |
14 | export let Editor = () => {
15 | let win = useWindowSize()
16 | let ref = useRef(null)
17 | let [entrypoint] = useEntrypoint()
18 | let [tab, changeTab] = useTab()
19 | let [construct, setConstruct] = useState(null)
20 | let [sketch, setSketch] = useState(null)
21 | let [error, setError] = useState(null)
22 |
23 | // When the entrypoint url loads, try to fetch it and catch build errors.
24 | useEffect(() => {
25 | if (entrypoint.url != null) {
26 | import(/* @vite-ignore */ `${entrypoint.url}?t=${entrypoint.timestamp}`)
27 | .then((pkg) => setConstruct(() => pkg.default))
28 | .catch(() => setError(entrypoint.url))
29 | }
30 | }, [entrypoint.url, entrypoint.timestamp])
31 |
32 | // Attach event listeners for uncaught errors.
33 | useEffect(() => {
34 | let onError = (e: ErrorEvent) => {
35 | e.preventDefault()
36 | setError(e.error)
37 | }
38 |
39 | let onRejection = (e: PromiseRejectionEvent) => {
40 | e.preventDefault()
41 | setError(e.reason.error)
42 | }
43 |
44 | window.addEventListener('error', onError)
45 | window.addEventListener('unhandledrejection', onRejection)
46 | return () => {
47 | window.removeEventListener('error', onError)
48 | window.removeEventListener('unhandledrejection', onRejection)
49 | }
50 | })
51 |
52 | // When the tab changes, if the sketch is stopped, restart it.
53 | useEffect(() => {
54 | if (!ref.current || !construct) return
55 |
56 | // Clean up any existing sketch artifacts.
57 | let el = ref.current
58 | while (el.firstChild) el.removeChild(el.firstChild)
59 | if (sketch) Sketch.detach(sketch)
60 |
61 | // Create a new sketch object.
62 | let { seed, layers, traits, config } = tab
63 | let s = Sketch.of({
64 | construct,
65 | container: el,
66 | layers: cloneDeep(layers),
67 | traits: cloneDeep(traits),
68 | config: cloneDeep(config),
69 | hash: hashSeed(seed),
70 | })
71 |
72 | // Listen for errors to show the error console.
73 | Error.stackTraceLimit = 50
74 | Sketch.on(s, 'error', setError)
75 | Sketch.play(s)
76 | setSketch(s)
77 | return () => Sketch.detach(s)
78 | }, [
79 | construct,
80 | win.width,
81 | win.height,
82 | tab.seed,
83 | tab.layers,
84 | tab.traits,
85 | tab.config,
86 | ])
87 |
88 | // When a sketch is created zoom it to fit the available space.
89 | useEffect(() => {
90 | if (sketch && ref.current) {
91 | let { offsetWidth, offsetHeight } = ref.current
92 | let [width, height] = Sketch.dimensions(sketch, 'pixel')
93 | changeTab((t) => {
94 | let ratio = Math.min(1, offsetWidth / width, offsetHeight / height)
95 | let zoom = ratio === 1 ? 1 : zoomOut(ratio)
96 | t.zoom = zoom
97 | })
98 | }
99 | }, [sketch])
100 |
101 | // When the tab's zoom level changes, reflect it in CSS tranforms.
102 | useLayoutEffect(() => {
103 | if (!ref.current) return
104 | let el = ref.current
105 | el.style.transform = `scale(${tab.zoom})`
106 | }, [tab.zoom])
107 |
108 | return (
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 | {error == null ? null : (
119 |
120 |
121 |
122 | )}
123 |
124 |
125 |
126 | {sketch && }
127 |
128 |
129 |
130 |
131 | )
132 | }
133 |
--------------------------------------------------------------------------------
/apps/electron/src/main/classes/tab.ts:
--------------------------------------------------------------------------------
1 | import Path from 'path'
2 | import crypto from 'node:crypto'
3 | import { BrowserView } from 'electron'
4 | import { RENDERER_URL } from '../env'
5 | import { Window } from './window'
6 | import { main } from './main'
7 | import { Draft } from 'immer'
8 | import { TabState } from '../../shared/store-state'
9 | import { Entrypoint } from './entrypoint'
10 | import log from 'electron-log'
11 |
12 | /** A `Tab` class holds a reference to a specific sketch file. */
13 | export class Tab {
14 | id: string
15 | view: BrowserView
16 |
17 | /** Construct a new `Tab` instance with `id`.. */
18 | constructor(id: string) {
19 | let preload = Path.resolve(__dirname, '../preload/index.js')
20 | let url = `${RENDERER_URL}#/tabs/${id}`
21 | let view = new BrowserView({ webPreferences: { preload } })
22 | this.id = id
23 | this.view = view
24 | log.info('Opening tab URL…', url)
25 | view.webContents.loadURL(url)
26 | }
27 |
28 | /**
29 | * Statics.
30 | */
31 |
32 | /** Create a new `Tab` with a sketch file `path`. */
33 | static create(path: string): Tab {
34 | let entrypoint = Entrypoint.load(path)
35 | let id = crypto.randomUUID()
36 | main.change((m) => {
37 | m.tabs[id] = {
38 | id,
39 | entrypointId: entrypoint.id,
40 | zoom: 0.01,
41 | seed: 1,
42 | config: {},
43 | traits: {},
44 | layers: {},
45 | }
46 | })
47 |
48 | let tab = new Tab(id)
49 | main.tabs[id] = tab
50 | return tab
51 | }
52 |
53 | /** Restore a saved tab by `id`. */
54 | static restore(id: string): Tab {
55 | let t = main.store.tabs[id]
56 | if (!t) throw new Error(`Cannot restore unknown tab: ${id}`)
57 | Entrypoint.restore(t.entrypointId)
58 | let tab = new Tab(id)
59 | main.tabs[id] = tab
60 | return tab
61 | }
62 |
63 | /** Get all the open tabs. */
64 | static all(): Tab[] {
65 | return Object.values(main.tabs)
66 | }
67 |
68 | /** Get the active tab in the active window. */
69 | static byActive(): Tab | null {
70 | let window = Window.byActive()
71 | if (!window || !window.activeTabId) return null
72 | let tab = Tab.byId(window.activeTabId)
73 | return tab
74 | }
75 |
76 | /** Get a tab by `id`. */
77 | static byId(id: string): Tab {
78 | let tab = main.tabs[id]
79 | if (!tab) throw new Error(`Cannot find tab by id: "${id}"`)
80 | return tab
81 | }
82 |
83 | /** Get a tab by `senderId`. */
84 | static bySenderId(senderId: number): Tab {
85 | let tab = Tab.all().find((t) => t.senderId === senderId)
86 | if (!tab) throw new Error(`Cannot find tab by sender id: ${senderId}`)
87 | return tab
88 | }
89 |
90 | /**
91 | * Getters & setters.
92 | */
93 |
94 | /** The electron tab's `webContents` id, for IPC messages. */
95 | get senderId(): number {
96 | return this.view.webContents.id
97 | }
98 |
99 | /** Get the tab's entrypoint. */
100 | get entrypoint() {
101 | return main.entrypoints[this.entrypointId]
102 | }
103 |
104 | /** Get the tab's entrypoint ID. */
105 | get entrypointId() {
106 | return main.store.tabs[this.id].entrypointId
107 | }
108 |
109 | /** Get the tab's parent `Window`. */
110 | get window() {
111 | let windows = Window.all()
112 | let window = windows.find((w) => w.tabIds.includes(this.id))
113 | if (!window) throw new Error(`Cannot find window for tab: ${this.id}`)
114 | return window
115 | }
116 |
117 | /** Update the tab's immutable state with an Immer `recipe` function. */
118 | change(recipe: (draft: Draft) => void): void {
119 | return main.change((s) => {
120 | recipe(s.tabs[this.id])
121 | })
122 | }
123 |
124 | /**
125 | * Actions.
126 | */
127 |
128 | /** Open the devtools inspector for the tab. */
129 | inspect() {
130 | log.info('Inspecting tab…', { id: this.id })
131 | let w = this.view.webContents
132 | if (w.isDevToolsOpened()) w.closeDevTools()
133 | w.openDevTools()
134 | }
135 |
136 | /** Reload the tab. */
137 | reload() {
138 | log.info('Reloading tab…', { id: this.id })
139 | this.view.webContents.reload()
140 | }
141 |
142 | /**
143 | * Events.
144 | */
145 |
146 | onClosed() {
147 | log.info('Received tab `closed` pseudo-event…', { id: this.id })
148 | let { id, entrypoint } = this
149 | if (!main.tabs[id]) return
150 |
151 | // If this is the only active tab for the entrypoint, shut it down too.
152 | if (entrypoint.tabs.length == 1) {
153 | entrypoint.close()
154 | }
155 |
156 | // If it's a deliberate close, remove the tab from storage.
157 | if (!main.isQuitting) {
158 | main.change((s) => {
159 | delete s.tabs[id]
160 | })
161 | }
162 |
163 | delete main.tabs[id]
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/apps/electron/src/renderer/components/fields/number-field.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useRef, useState } from 'react'
2 | import { useLatest } from 'react-use'
3 |
4 | // A tiny empty image for replacing the default drag image.
5 | let DRAG_IMAGE = new Image(0, 0)
6 | DRAG_IMAGE.src =
7 | 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
8 |
9 | export let NumberField = (props: {
10 | value: number
11 | step: number
12 | multiplier?: number
13 | label: string
14 | icon?: React.ReactNode
15 | min?: number
16 | max?: number
17 | valueClassName?: string
18 | onChange: (value: number) => void
19 | }) => {
20 | let {
21 | value,
22 | icon,
23 | step,
24 | label,
25 | multiplier = 10,
26 | min = -Infinity,
27 | max = Infinity,
28 | valueClassName = '',
29 | onChange,
30 | } = props
31 | let [prevValue, setPrevValue] = useState(value)
32 | let [text, setText] = useState(toText(value, step))
33 | let [focused, setFocused] = useState(false)
34 | let latestValue = useLatest(value)
35 | let clickRef = useRef(false)
36 | let inputRef = useRef(null)
37 | let dragPositionRef = useRef(0)
38 | let dragHandlerRef = useRef<(e: DragEvent) => void | null>()
39 |
40 | // If the value changes, sync our internal text state.
41 | if (value !== prevValue) {
42 | let text = toText(value, step)
43 | setText(text)
44 | setPrevValue(value)
45 | }
46 |
47 | // Set a new value from a number or string, avoiding dupes, keeping state.
48 | let setValue = useCallback(
49 | (v: number | string) => {
50 | let val = Number(v)
51 | if (isNaN(val)) return
52 | val = Math.min(max, Math.max(min, val))
53 | if (val === latestValue.current) return
54 | onChange(val)
55 | let text = toText(val, step)
56 | setText(text)
57 | },
58 | [onChange, setText]
59 | )
60 |
61 | return (
62 |
146 | )
147 | }
148 |
149 | /** Print a `number` as a string with precision equal to its `step`. */
150 | function toText(number: number, step: number): string {
151 | if (number === 0) return `0`
152 | let rounded = Math.round(number / step) * step
153 | let isRound = isRoughlyEqual(number, rounded)
154 | let precision = isRound ? getPrecision(step) : getPrecision(number)
155 | let string = number.toFixed(precision)
156 | return string
157 | }
158 |
159 | /** Get the precision of a `number`. */
160 | function getPrecision(number: number): number {
161 | let [, decimals] = String(number).split('.')
162 | return decimals?.length ?? 0
163 | }
164 |
165 | /** Check if a numbers `a` and `b` are equal within a small precision. */
166 | function isRoughlyEqual(a: number, b: number) {
167 | return Math.abs(a - b) <= 0.000001
168 | }
169 |
--------------------------------------------------------------------------------
/packages/void/readme.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | A toolkit for making generative art.
9 |
10 |
11 | Introduction •
12 | Examples •
13 | Documentation
14 |
15 |
16 |
17 | Void makes it easy to create and explore generative art. It gives you the workflows you know from modern graphics programs, paired with a simple, powerful library for building sketches with [HTML's `