├── 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 | 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 |
17 |
18 |
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 | Void 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 ``](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API). 18 | 19 | - **Tweak variables.** The traits of a sketch can be changed directly in the UI, so you can experiment quickly, and create a variety of different outputs. 20 | 21 | - **Export artwork.** The output of a sketch can be exported to raster formats like PNG or JPG, as well as vector formats like SVG out of the box! 22 | 23 | - **Control randomness.** Changing the random seed for a sketch allows you to reproduce existing outputs, or create entirely new outputs. 24 | 25 | - **Customize layout.** Use common presets like `Letter`, `A4`, or `1080p`, or define a custom size, add margins, change orientations, etc. 26 | 27 | - **Import dependencies.** Sketches are just JavaScript (or TypeScript) files, so you can import useful helpers from [npm](https://www.npmjs.com/) packages or neighboring files as usual. 28 | 29 | - **Bundle efficiently.** The Void library is designed to be completely treeshake-able, so it produces the absolute smallest bundle sizes when packaging up your code. 30 | 31 | - **Feel familiar.** Void's UI is inspired by modern tools like [Figma](https://www.figma.com/) and [Blender](https://www.blender.org/), its API is inspired by creative coding frameworks like [P5.js](https://p5js.org/) and [canvas-sketch](https://github.com/mattdesl/canvas-sketch). 32 | 33 |
34 |

35 | 36 |

37 |
38 | 39 | ### Introduction 40 | 41 | To get started, download the Void desktop app: 42 | 43 | 44 | Download for Mac (Apple Silicon) 45 | 46 | 47 | Download for Mac (Intel) 48 | 49 | 50 | Download for Windows 51 | 52 | 53 | Download for Linux 54 | 55 |
56 |
57 | 58 | Install the `void` package: 59 | 60 | ```bash 61 | npm install --save void 62 | ``` 63 | 64 | Create a new sketch file: 65 | 66 | ```js 67 | import { Void } from 'void' 68 | 69 | export default function () { 70 | let { width, height } = Void.settings([300, 300, 'px']) 71 | let radius = Void.int('radius', 10, 150) 72 | let ctx = Void.layer() 73 | ctx.beginPath() 74 | ctx.arc(width / 2, height / 2, radius, 0, Math.PI * 2, false) 75 | ctx.fill() 76 | } 77 | ``` 78 | 79 | Then open the sketch file with the Void app: 80 | 81 | ![A screenshot of the basic sketch in the Void app.](./docs/images/introduction.png) 82 | 83 | If you see a black circle on the screen, congrats! 84 | 85 | The Radius trait has a randomly generated value. You can change it in the sidebar and the sketch will update in real time. This is a simple sketch that draws just one shape, but you can do a lot more… 86 | 87 |
88 | 89 | ### Examples 90 | 91 | Void is designed to make it easy to quickly iterate on sketches, so you can explore new ideas quickly. To get a sense for what's possible, here are some examples: 92 | 93 | - **Basics** 94 | - [Introduction](./examples/basics/introduction.js) 95 | - [Layers](./examples/basics/layers.js) 96 | - [Noise](./examples/basics/noise.js) 97 | - [Animation](./examples/basics/animation.js) 98 | - [Pointer](./examples/basics/pointer.js) 99 | - **Classics** 100 | - [Bill Kolomyjec](./examples/classics/bill-kolomyjec.js) — [original](http://recodeproject.com/artwork/v2n3random-squares) 101 | - [Ellsworth Kelly](./examples/classics/ellsworth-kelly.js) — [original](https://www.moma.org/collection/works/35484) 102 | - [François Morellet](./examples/classics/francois-morellet.js) — [original](https://www.wikiart.org/en/francois-morellet/tirets-neon-0-90-avec-4-rythmes-interferents-191) 103 | - [Georg Nees](./examples/classics/georg-nees.js) — [original](https://collections.vam.ac.uk/item/O221321/schotter-print-nees-georg/) 104 | - [Vera Molnár](./examples/classics/vera-molnar.js) — [original](https://pratiques-picturales.net/article63.html) 105 | 106 | Download and open any example file in the Void desktop app to see their output. 107 | 108 |
109 | 110 | ### Documentation 111 | 112 | Void's API is designed to be extremely simple to use. It gives you a handful of tools that are useful when making generative art, and it delegates the rendering itself to the HTML [``](https://www.google.com/search?client=firefox-b-1-d&q=mdn+canvas) element. 113 | 114 | It's built as a series of helper functions: 115 | 116 | - [**Canvas**](./docs/void.md#canvas) 117 | - [`Void.draw()`](./docs/void.md#voiddraw) 118 | - [`Void.layer()`](./docs/void.md#voidlayer) 119 | - [`Void.settings()`](./docs/void.md#voidsettings) 120 | - [**Traits**](./docs/void.md#traits) 121 | - [`Void.bool()`](./docs/void.md#voidbool) 122 | - [`Void.float()`](./docs/void.md#voidfloat) 123 | - [`Void.int()`](./docs/void.md#voidint) 124 | - [`Void.pick()`](./docs/void.md#voidpick) 125 | - [**Interaction**](./docs/void.md#interaction) 126 | - [`Void.event()`](./docs/void.md#voidevent) 127 | - [`Void.keyboard()`](./docs/void.md#voidkeyboard) 128 | - [`Void.pointer()`](./docs/void.md#voidpointer) 129 | - [**Utils**](./docs/void.md#utils) 130 | - [`Void.convert()`](./docs/void.md#voidconvert) 131 | - [`Void.fork()`](./docs/void.md#voidfork) 132 | - [`Void.random()`](./docs/void.md#voidrandom) 133 | 134 |
135 | 136 | ### License 137 | 138 | Void is open-source and [MIT-licensed](./License.md). If you run into issues or think of improvements, all contributions are very welcome! Feel free to [open an issue](https://github.com/ianstormtaylor/void/issues) or [submit a pull request](https://github.com/ianstormtaylor/void/pulls). 139 | 140 |
141 | 142 | ### Thanks 143 | 144 | Thanks to [Eric Johnson](https://github.com/edj-boston) for letting us use the `void` package name on NPM! Thanks to [Lauren Lee McCarthy](https://github.com/lmccart) and [Matt DesLauriers](https://github.com/mattdesl) for creating P5.js and canvas-sketch which served as inspiration for the API. 145 | -------------------------------------------------------------------------------- /apps/electron/src/main/classes/main.ts: -------------------------------------------------------------------------------- 1 | import { app, dialog, Menu, session } from 'electron' 2 | import { Draft } from 'immer' 3 | import ElectronStore from 'electron-store' 4 | import { StoreState, initialState } from '../../shared/store-state' 5 | import { IS_DEV, IS_MAC, IS_PROD, MODE } from '../env' 6 | import * as ENV from '../env' 7 | import { initializeIpc as loadIpc } from '../ipc' 8 | import { appMenu, dockMenu } from '../menus' 9 | import { Tab } from './tab' 10 | import { Window } from './window' 11 | import { Entrypoint } from './entrypoint' 12 | import { createMainStore } from '../../shared/store/main' 13 | import { Store as SharedStore } from '../../shared/store/base' 14 | import updateElectronApp from 'update-electron-app' 15 | import log from 'electron-log' 16 | import unhandled from 'electron-unhandled' 17 | import fixPath from 'fix-path' 18 | 19 | /** The `Main` object stores state about the entire app on the main thread. */ 20 | export class Main { 21 | /** A reference to each `Window` instance by id. */ 22 | windows: Record 23 | 24 | /** A reference to each `Tab` instance by id. */ 25 | tabs: Record 26 | 27 | /** A reference to each `Entrypoint` instance by id. */ 28 | entrypoints: Record 29 | 30 | /** A flag to know when windows are closing by quit or by choice. */ 31 | isQuitting: boolean 32 | 33 | /** The persistent store that gets saved to a JSON file. */ 34 | #store: ElectronStore 35 | 36 | /** The shared store that gets synced to renderer processes. */ 37 | #shared: SharedStore 38 | 39 | /** Create a new `Main` singleton. */ 40 | constructor() { 41 | log.warn('Starting main process…', { 42 | ENV, 43 | app: { 44 | name: app.getName(), 45 | version: app.getVersion(), 46 | path: app.getAppPath(), 47 | isPackaged: app.isPackaged, 48 | }, 49 | }) 50 | 51 | // Catch unhandled errors. 52 | unhandled() 53 | 54 | // Fix the PATH so that `node` is available for spawning processes. 55 | fixPath() 56 | 57 | // Automatically try to keep the app up to date. 58 | if (IS_PROD) { 59 | updateElectronApp({ logger: log }) 60 | } 61 | 62 | // Start the config store, and a shared store for communicating with renderers. 63 | let store = new ElectronStore({ 64 | defaults: initialState, 65 | name: `config-${MODE}`, 66 | }) 67 | 68 | let shared = createMainStore({ 69 | entrypoints: store.get('entrypoints'), 70 | tabs: store.get('tabs'), 71 | windows: store.get('windows'), 72 | }) 73 | 74 | this.#store = store 75 | this.#shared = shared 76 | this.isQuitting = false 77 | this.windows = {} 78 | this.tabs = {} 79 | this.entrypoints = {} 80 | 81 | shared.subscribe((state) => { 82 | store.set('entrypoints', state.entrypoints) 83 | store.set('tabs', state.tabs) 84 | store.set('windows', state.windows) 85 | }) 86 | 87 | app.on('ready', async () => { 88 | log.info('Received `ready` event') 89 | 90 | // If there is already an app instance, quit so only one is ever open. 91 | if (!app.requestSingleInstanceLock()) { 92 | log.info('ARGV', process.argv) 93 | log.info('Quitting for single instance lock…') 94 | this.quit() 95 | return 96 | } 97 | 98 | // Load the IPC channels and menu items. 99 | loadIpc() 100 | Menu.setApplicationMenu(appMenu) 101 | if (IS_MAC) app.dock.setMenu(dockMenu) 102 | 103 | // Load the React Devtools extension. 104 | // https://github.com/BlackHole1/electron-devtools-vendor#usage 105 | if (IS_DEV) { 106 | let { REACT_DEVELOPER_TOOLS } = require('electron-devtools-vendor') 107 | await session.defaultSession.loadExtension(REACT_DEVELOPER_TOOLS, { 108 | allowFileAccess: true, 109 | }) 110 | } 111 | 112 | // Try to restore any saved windows. 113 | this.restore() 114 | 115 | // If there is still no active window, create one. 116 | if (!Window.byActive()) { 117 | let window = Window.create() 118 | window.show() 119 | } 120 | 121 | // If there is a file path with the process, open it. 122 | // if (process.argv.length >= 2) { 123 | // let path = process.argv[1] 124 | // this.openFile(path) 125 | // } 126 | }) 127 | 128 | // On macOS, this fires when clicking the dock icon. 129 | app.on('activate', () => { 130 | log.info('Received `activated` event') 131 | let window = Window.byActive() 132 | if (window) { 133 | window.focus() 134 | } else { 135 | let window = Window.create() 136 | window.show() 137 | } 138 | }) 139 | 140 | app.on('open-file', async (e, path) => { 141 | log.info('Received `open-file` event') 142 | e.preventDefault() 143 | await app.whenReady() 144 | let window = Window.byFocused() ?? Window.create() 145 | window.openTab(path) 146 | window.show() 147 | }) 148 | 149 | app.on('before-quit', () => { 150 | log.info('Received `before-quit` event') 151 | this.isQuitting = true 152 | }) 153 | 154 | app.on('will-quit', () => { 155 | log.info('Received `will-quit` event') 156 | }) 157 | 158 | app.on('quit', () => { 159 | log.info('Received `quit` event') 160 | this.isQuitting = false 161 | }) 162 | 163 | // On macOS, don't quit when all windows are closed. 164 | app.on('window-all-closed', () => { 165 | log.info('Received `window-all-closed` event') 166 | if (!IS_MAC) this.quit() 167 | }) 168 | } 169 | 170 | /** 171 | * Getters & setters. 172 | */ 173 | 174 | /** Get the main store's current state. */ 175 | get store() { 176 | return this.#shared.get() 177 | } 178 | 179 | /** Set the main store's state using an Immer `recipe` function. */ 180 | change(recipe: (draft: Draft) => void): void { 181 | return this.#shared.change(recipe) 182 | } 183 | 184 | /** 185 | * Actions. 186 | */ 187 | 188 | /** Clear the persistent storage. */ 189 | clear() { 190 | log.info('Clearing storage…') 191 | this.#store.clear() 192 | } 193 | 194 | /** Open sketches with the active window, or create a new one. */ 195 | async open() { 196 | log.info('Showing open files dialog…') 197 | let result = await dialog.showOpenDialog({ 198 | properties: ['openFile', 'multiSelections'], 199 | }) 200 | 201 | let window = Window.byFocused() ?? Window.create() 202 | for (let path of result.filePaths) { 203 | window.openTab(path) 204 | } 205 | window.show() 206 | } 207 | 208 | /** Quit the app. */ 209 | quit() { 210 | log.info('Quitting…') 211 | app.quit() 212 | } 213 | 214 | /** Restore any saved windows. */ 215 | restore() { 216 | for (let id in this.store.windows) { 217 | log.info('Restoring window…', { id }) 218 | let window = Window.restore(id) 219 | window.show() 220 | } 221 | } 222 | 223 | /** Restart an app after exiting. */ 224 | restart() { 225 | log.info('Restarting app…') 226 | app.relaunch() 227 | this.quit() 228 | } 229 | } 230 | 231 | /** Create a singleton `Main` instance. */ 232 | export let main = new Main() 233 | -------------------------------------------------------------------------------- /apps/electron/src/main/classes/entrypoint.ts: -------------------------------------------------------------------------------- 1 | import Path from 'path' 2 | import crypto from 'node:crypto' 3 | import Esbuild from 'esbuild-wasm' 4 | import Fs from 'fs' 5 | import Http from 'http' 6 | import { temporaryDirectory } from 'tempy' 7 | import { main } from './main' 8 | import { Draft } from 'immer' 9 | import { EntrypointState } from '../../shared/store-state' 10 | import { Tab } from './tab' 11 | import log from 'electron-log' 12 | import { app } from 'electron' 13 | 14 | /** A `Entrypoint` class holds a reference to a specific sketch file. */ 15 | export class Entrypoint { 16 | id: string 17 | #builder: Esbuild.BuildResult | null 18 | #watcher: Esbuild.BuildResult | null 19 | #server: Http.Server | null 20 | 21 | /** Constructor a new `Entrypoint` instance by `id`. */ 22 | constructor(id: string) { 23 | this.id = id 24 | this.#builder = null 25 | this.#watcher = null 26 | this.#server = null 27 | this.serve() 28 | 29 | // Ensure that the running processes are stopped when the app quits. 30 | app.on('will-quit', () => { 31 | this.close() 32 | }) 33 | } 34 | 35 | /** 36 | * Statics. 37 | */ 38 | 39 | /** Load an entrypoint by `path`, reusing an existing one if possible. */ 40 | static load(path: string): Entrypoint { 41 | let match = Object.values(main.entrypoints).find((s) => s.path === path) 42 | if (match) { 43 | match.serve() 44 | return match 45 | } 46 | 47 | let s = Object.values(main.store.entrypoints).find((s) => s.path === path) 48 | if (s) return Entrypoint.restore(s.id) 49 | 50 | let id = crypto.randomUUID() 51 | main.change((m) => { 52 | m.entrypoints[id] = { 53 | id, 54 | path, 55 | url: null, 56 | timestamp: null, 57 | } 58 | }) 59 | let entrypoint = new Entrypoint(id) 60 | main.entrypoints[id] = entrypoint 61 | return entrypoint 62 | } 63 | 64 | /** Restore a saved entrypoint. */ 65 | static restore(id: string): Entrypoint { 66 | let s = main.store.entrypoints[id] 67 | if (!s) throw new Error(`Cannot restore unknown entrypoint: ${id}`) 68 | let entrypoint = new Entrypoint(id) 69 | main.entrypoints[id] = entrypoint 70 | return entrypoint 71 | } 72 | 73 | /** 74 | * Getters & setters. 75 | */ 76 | 77 | /** Get the entrypoint's URL. */ 78 | get url(): string | null { 79 | return main.store.entrypoints[this.id].url 80 | } 81 | 82 | /** Get the entrypoint's path. */ 83 | get path(): string { 84 | return main.store.entrypoints[this.id].path 85 | } 86 | 87 | /** Get the entrypoint's open tabs. */ 88 | get tabs(): Tab[] { 89 | return Object.values(main.store.tabs) 90 | .filter((t) => t.entrypointId == this.id) 91 | .map((t) => main.tabs[t.id]) 92 | } 93 | 94 | /** Update the entrypoint's immutable state with an Immer `recipe` function. */ 95 | change(recipe: (draft: Draft) => void): void { 96 | return main.change((s) => { 97 | recipe(s.entrypoints[this.id]) 98 | }) 99 | } 100 | 101 | /** 102 | * Actions. 103 | */ 104 | 105 | /** Serve the entrypoint with esbuild from memory. */ 106 | async serve() { 107 | if (this.#builder || this.#watcher || this.#server) { 108 | log.info('Already serving the sketch, returning early') 109 | return 110 | } 111 | 112 | let { id, path } = this 113 | let file = Path.basename(path, Path.extname(path)) 114 | let jsFile = `${file}.js` 115 | let tmpdir = temporaryDirectory() 116 | let failure: Esbuild.BuildFailure | undefined 117 | log.info('Serving entrypoint…', { id, path, tmpdir }) 118 | 119 | try { 120 | this.#watcher = await Esbuild.build({ 121 | entryPoints: [path], 122 | outdir: tmpdir, 123 | write: false, 124 | watch: { 125 | onRebuild: (error) => { 126 | if (error) { 127 | log.error('Esbuild watcher error', { id, path, error }) 128 | } else { 129 | log.info('Esbuild watcher rebuild', { id, path }) 130 | } 131 | 132 | this.change((e) => { 133 | e.timestamp = Date.now() 134 | }) 135 | }, 136 | }, 137 | }) 138 | } catch (e) { 139 | failure = e as Esbuild.BuildFailure 140 | log.error('Esbuild failure', { 141 | id, 142 | path, 143 | failure, 144 | cause: failure.cause, 145 | errors: failure.errors, 146 | warnings: failure.warnings, 147 | }) 148 | } 149 | 150 | this.#server = Http.createServer(async (req, res) => { 151 | if (!req.url) return 152 | let { url, headers } = req 153 | log.info('Incoming entrypoint server request…', { 154 | id, 155 | path, 156 | url, 157 | headers, 158 | }) 159 | 160 | let { pathname } = new URL(`http://${headers.host}${url}`) 161 | let isJs = pathname === `/${jsFile}` 162 | 163 | if (pathname === '/esbuild-errors') { 164 | log.info('Handling esbuild build-time errors request…') 165 | res.statusCode = 200 166 | res.setHeader('Access-Control-Allow-Origin', '*') 167 | res.setHeader('Content-Type', 'application/json') 168 | res.write(JSON.stringify(failure?.errors ?? [])) 169 | res.end() 170 | } 171 | 172 | if (!pathname.startsWith(`/${jsFile}`)) { 173 | log.error('Requested file not found', { id, path, url, headers }) 174 | res.statusCode = 404 175 | res.end() 176 | return 177 | } 178 | 179 | try { 180 | if (this.#builder) { 181 | log.info('Rebuilding with esbuild…', { id, path }) 182 | await this.#builder.rebuild!() 183 | } else { 184 | log.info('Starting esbuild…', { id, path }) 185 | this.#builder = await Esbuild.build({ 186 | entryPoints: [path], 187 | outdir: tmpdir, 188 | bundle: true, 189 | sourcemap: true, 190 | sourceRoot: path, 191 | incremental: true, 192 | format: 'esm', 193 | }) 194 | } 195 | } catch (e) { 196 | failure = e as Esbuild.BuildFailure 197 | log.error('Esbuild building error', { 198 | id, 199 | path, 200 | failure, 201 | cause: failure.cause, 202 | errors: failure.errors, 203 | warnings: failure.warnings, 204 | }) 205 | res.statusCode = 500 206 | res.end() 207 | return 208 | } 209 | 210 | log.info('Handling esbuild request…', { id, path }) 211 | res.statusCode = 200 212 | res.setHeader('Access-Control-Allow-Origin', '*') 213 | res.setHeader('Content-Type', isJs ? 'text/javascript' : 'text/plain') 214 | let outfile = Path.resolve(tmpdir, pathname.slice(1)) 215 | let stream = Fs.createReadStream(outfile) 216 | stream.pipe(res, { end: true }) 217 | }) 218 | 219 | this.#server.listen() 220 | let { port } = this.#server.address() as any 221 | setImmediate(() => { 222 | this.change((e) => { 223 | e.url = `http://localhost:${port}/${jsFile}` 224 | e.timestamp = Date.now() 225 | }) 226 | }) 227 | } 228 | 229 | /** Shut down the entrypoint's running processes. */ 230 | close() { 231 | log.info('Stopping entrypoint…', { id: this.id, path: this.path }) 232 | if (this.#watcher && this.#watcher.stop != null) { 233 | this.#watcher.stop() 234 | } 235 | 236 | if (this.#builder && this.#builder.stop != null) { 237 | this.#builder.rebuild!.dispose() 238 | } 239 | 240 | if (this.#server) { 241 | this.#server.close() 242 | } 243 | 244 | this.#builder = null 245 | this.#watcher = null 246 | this.#server = null 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /packages/void/test/void.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect as e } from 'vitest' 2 | import { Void, Sketch } from '../src' 3 | 4 | // Run in the context of a sketch with fixed seed. 5 | let run = (fn: (sketch: Sketch) => void) => { 6 | let sketch = Sketch.of({ 7 | construct: () => {}, 8 | container: {} as any, 9 | el: {} as any, 10 | hash: '0x0123456789abcdef0123456789abcdef', 11 | }) 12 | 13 | Sketch.exec(sketch, () => fn(sketch)) 14 | } 15 | 16 | // Fill an array of length `n` with the result of `fn`. 17 | let fill = (length: number, fn: (i: number) => T): T[] => { 18 | return Array(length).fill(null).map(fn) 19 | } 20 | 21 | // Get the mean of a set of `numbers`. 22 | let mean = (...numbers: number[]): number => { 23 | return numbers.reduce((s, n) => s + n, 0) / numbers.length 24 | } 25 | 26 | test('Void.bool', () => { 27 | run((sketch) => { 28 | let a = Void.bool('a') 29 | let b = Void.bool('b') 30 | let c = Void.bool('c') 31 | let d = Void.bool('d') 32 | e(a).toEqual(true) 33 | e(b).toEqual(true) 34 | e(c).toEqual(true) 35 | e(d).toEqual(false) 36 | e(sketch.traits.a).toEqual(a) 37 | e(sketch.traits.b).toEqual(b) 38 | e(sketch.traits.c).toEqual(c) 39 | e(sketch.traits.d).toEqual(d) 40 | let a2 = Void.bool('a') 41 | let b2 = Void.bool('b') 42 | let c2 = Void.bool('c') 43 | let d2 = Void.bool('d') 44 | e(a2).toEqual(a) 45 | e(b2).toEqual(b) 46 | e(c2).toEqual(c) 47 | e(d2).toEqual(d) 48 | }) 49 | }) 50 | 51 | test('Void.convert', () => { 52 | e(Void.convert(1, 'px')).toEqual(1) 53 | // imperial 54 | e(Void.convert(1, 'pt')).toEqual(96 / 72) 55 | e(Void.convert(1, 'pc')).toEqual(96 / 6) 56 | e(Void.convert(1, 'in')).toEqual(96) 57 | e(Void.convert(1, 'ft')).toEqual(96 * 12) 58 | e(Void.convert(1, 'yd')).toEqual(96 * 12 * 3) 59 | // metric 60 | e(Void.convert(1, 'mm')).toBeCloseTo(3.779) 61 | e(Void.convert(1, 'cm')).toBeCloseTo(37.795) 62 | e(Void.convert(1, 'm')).toBeCloseTo(3779.527) 63 | // to specific units 64 | e(Void.convert(1, 'in', 'in')).toEqual(1) 65 | e(Void.convert(1, 'ft', 'in')).toEqual(12) 66 | e(Void.convert(1, 'cm', 'mm')).toEqual(10) 67 | e(Void.convert(1, 'in', 'cm')).toEqual(2.54) 68 | // custom dpi 69 | e(Void.convert(1, 'in', { dpi: 10 })).toEqual(10) 70 | // custom precision 71 | e(Void.convert(1, 'mm', { precision: 0 })).toBeCloseTo(3.779) 72 | e(Void.convert(1, 'mm', { precision: 1 })).toEqual(3) 73 | e(Void.convert(1, 'mm', { precision: 1.5 })).toEqual(3) 74 | e(Void.convert(1, 'mm', { precision: 2 })).toEqual(2) 75 | }) 76 | 77 | test('Void.draw', () => { 78 | run((sketch) => { 79 | let draw = () => {} 80 | Void.draw(draw) 81 | e(sketch.draw).toEqual(draw) 82 | }) 83 | }) 84 | 85 | test.skip('event') 86 | 87 | test('Void.float', () => { 88 | run((sketch) => { 89 | let a = Void.float('a', 0, 5) 90 | let b = Void.float('b', 0, 5) 91 | let c = Void.float('c', 0, 5) 92 | let d = Void.float('d', 0, 5) 93 | e(a).toBeCloseTo(0.399) 94 | e(b).toBeCloseTo(0.582) 95 | e(c).toBeCloseTo(1.876) 96 | e(d).toBeCloseTo(3.868) 97 | e(sketch.traits.a).toEqual(a) 98 | e(sketch.traits.b).toEqual(b) 99 | e(sketch.traits.c).toEqual(c) 100 | e(sketch.traits.d).toEqual(d) 101 | let a2 = Void.float('a', 5, 10) 102 | let b2 = Void.float('b', 5, 10) 103 | let c2 = Void.float('c', 5, 10) 104 | let d2 = Void.float('d', 5, 10) 105 | e(a2).toEqual(a) 106 | e(b2).toEqual(b) 107 | e(c2).toEqual(c) 108 | e(d2).toEqual(d) 109 | }) 110 | 111 | run(() => { 112 | let sample = fill(10000, () => Void.random(0, 5)) 113 | e(mean(...sample)).toBeCloseTo(2.5, 1) 114 | }) 115 | }) 116 | 117 | test('Void.fork', () => { 118 | run(() => { 119 | Void.fork(() => {}) 120 | e(Void.random()).toBeCloseTo(0.572) 121 | }) 122 | 123 | run(() => { 124 | Void.fork(() => { 125 | for (let i = 0; i < 99; i++) Void.random() 126 | }) 127 | e(Void.random()).toBeCloseTo(0.572) 128 | }) 129 | }) 130 | 131 | test('Void.int', () => { 132 | run((sketch) => { 133 | let a = Void.int('a', 0, 5) 134 | let b = Void.int('b', 0, 5) 135 | let c = Void.int('c', 0, 5) 136 | let d = Void.int('d', 0, 5) 137 | e(a).toEqual(0) 138 | e(b).toEqual(0) 139 | e(c).toEqual(2) 140 | e(d).toEqual(4) 141 | e(sketch.traits.a).toEqual(a) 142 | e(sketch.traits.b).toEqual(b) 143 | e(sketch.traits.c).toEqual(c) 144 | e(sketch.traits.d).toEqual(d) 145 | let a2 = Void.int('a', 5, 10) 146 | let b2 = Void.int('b', 5, 10) 147 | let c2 = Void.int('c', 5, 10) 148 | let d2 = Void.int('d', 5, 10) 149 | e(a2).toEqual(a) 150 | e(b2).toEqual(b) 151 | e(c2).toEqual(c) 152 | e(d2).toEqual(d) 153 | }) 154 | 155 | run(() => { 156 | let sample = fill(10000, () => Void.random(0, 5)) 157 | e(mean(...sample)).toBeCloseTo(2.5, 1) 158 | }) 159 | }) 160 | 161 | test.skip('Void.keyboard') 162 | 163 | test.skip('Void.layer') 164 | 165 | test.skip('Void.pointer') 166 | 167 | test('Void.pick', () => { 168 | run((sketch) => { 169 | let a = Void.pick('a', [1, 2, 3]) 170 | let b = Void.pick('b', [1, 2, 3]) 171 | let c = Void.pick('c', [1, 2, 3]) 172 | let d = Void.pick('d', [1, 2, 3]) 173 | e(a).toEqual(1) 174 | e(b).toEqual(1) 175 | e(c).toEqual(2) 176 | e(d).toEqual(3) 177 | e(sketch.traits.a).toEqual('1') 178 | e(sketch.traits.b).toEqual('1') 179 | e(sketch.traits.c).toEqual('2') 180 | e(sketch.traits.d).toEqual('3') 181 | }) 182 | }) 183 | 184 | test('Void.random', () => { 185 | run(() => { 186 | // simple 187 | e(Void.random()).toBeCloseTo(0.079) 188 | e(Void.random()).toBeCloseTo(0.116) 189 | e(Void.random()).toBeCloseTo(0.375) 190 | // float 191 | e(Void.random(0, 10)).toBeCloseTo(7.737) 192 | e(Void.random(0, 10)).toBeCloseTo(5.726) 193 | e(Void.random(0, 10)).toBeCloseTo(4.933) 194 | // step 195 | e(Void.random(0, 10, 0.1)).toEqual(5.6) 196 | e(Void.random(0, 10, 0.1)).toEqual(6.3) 197 | e(Void.random(0, 10, 0.1)).toEqual(6.5) 198 | // int 199 | e(Void.random(0, 10, 2)).toEqual(4) 200 | e(Void.random(0, 10, 2)).toEqual(6) 201 | e(Void.random(0, 10, 2)).toEqual(10) 202 | }) 203 | 204 | run(() => { 205 | let sample = fill(100000, () => Void.random()) 206 | e(mean(...sample)).toBeCloseTo(0.5, 2) 207 | }) 208 | 209 | run(() => { 210 | let sample = fill(100000, () => Void.random(0, 10)) 211 | e(mean(...sample)).toBeCloseTo(5, 1) 212 | }) 213 | 214 | run(() => { 215 | let sample = fill(100000, () => Void.random(0, 10, 0.1)) 216 | e(mean(...sample)).toBeCloseTo(5, 1) 217 | }) 218 | 219 | run(() => { 220 | let sample = fill(100000, () => Void.random(0, 10, 2)) 221 | e(mean(...sample)).toBeCloseTo(5, 1) 222 | }) 223 | }) 224 | 225 | test('Void.settings', () => { 226 | run((sketch) => { 227 | let s = Void.settings([500, 500, 'px']) 228 | e(sketch.settings).toEqual(s) 229 | e(s).toEqual({ 230 | dpi: 96, 231 | fps: 60, 232 | frames: Infinity, 233 | height: 500, 234 | margin: [0, 0, 0, 0], 235 | precision: 1, 236 | units: 'px', 237 | width: 500, 238 | }) 239 | }) 240 | 241 | run((sketch) => { 242 | let s = Void.settings([6, 6, 'in']) 243 | e(sketch.settings).toEqual(s) 244 | e(s).toEqual({ 245 | dpi: 96, 246 | fps: 60, 247 | frames: Infinity, 248 | height: 6, 249 | margin: [0, 0, 0, 0], 250 | precision: 0, 251 | units: 'in', 252 | width: 6, 253 | }) 254 | }) 255 | 256 | run((sketch) => { 257 | let s = Void.settings([20, 20, 'cm']) 258 | e(sketch.settings).toEqual(s) 259 | e(s).toEqual({ 260 | dpi: 96, 261 | fps: 60, 262 | frames: Infinity, 263 | height: 20, 264 | margin: [0, 0, 0, 0], 265 | precision: 0, 266 | units: 'cm', 267 | width: 20, 268 | }) 269 | }) 270 | 271 | run((sketch) => { 272 | let s = Void.settings({ 273 | dimensions: [100, 100, 'mm'], 274 | margin: [10, 'mm'], 275 | }) 276 | e(sketch.settings).toEqual(s) 277 | e(s).toEqual({ 278 | dpi: 96, 279 | fps: 60, 280 | frames: Infinity, 281 | height: 80, 282 | margin: [10, 10, 10, 10], 283 | precision: 0, 284 | units: 'mm', 285 | width: 80, 286 | }) 287 | }) 288 | 289 | run((sketch) => { 290 | let s = Void.settings({ 291 | dimensions: [100, 100, 'mm'], 292 | units: 'px', 293 | }) 294 | e(sketch.settings).toEqual(s) 295 | e(s).toEqual({ 296 | dpi: 96, 297 | fps: 60, 298 | frames: Infinity, 299 | height: 377, 300 | margin: [0, 0, 0, 0], 301 | precision: 1, 302 | units: 'px', 303 | width: 377, 304 | }) 305 | }) 306 | 307 | run((sketch) => { 308 | let s = Void.settings({ 309 | dimensions: [6, 6, 'in'], 310 | margin: [3, 'cm'], 311 | units: 'mm', 312 | precision: [1, 'mm'], 313 | }) 314 | e(sketch.settings).toEqual(s) 315 | e(s).toEqual({ 316 | dpi: 96, 317 | fps: 60, 318 | frames: Infinity, 319 | height: 92, 320 | margin: [30, 30, 30, 30], 321 | precision: 1, 322 | units: 'mm', 323 | width: 92, 324 | }) 325 | }) 326 | }) 327 | -------------------------------------------------------------------------------- /apps/electron/src/main/classes/window.ts: -------------------------------------------------------------------------------- 1 | import Path from 'path' 2 | import crypto from 'node:crypto' 3 | import { BrowserWindow } from 'electron' 4 | import { RENDERER_URL } from '../env' 5 | import { Tab } from './tab' 6 | import { main } from './main' 7 | import { Draft } from 'immer' 8 | import { WindowState } from '../../shared/store-state' 9 | import log from 'electron-log' 10 | 11 | /** A `Window` class to hold state for a series of tabs. */ 12 | export class Window { 13 | /** A unique (persistable) identifier for the window. */ 14 | id: string 15 | 16 | /** A private reference to the Electron window. */ 17 | #window: BrowserWindow 18 | 19 | /** Create a new window. */ 20 | constructor(id: string) { 21 | let preload = Path.resolve(__dirname, '../preload/index.js') 22 | let url = `${RENDERER_URL}#/windows/${id}` 23 | let window = new BrowserWindow({ 24 | x: 0, 25 | y: 0, 26 | width: 1250, 27 | height: 750, 28 | show: false, 29 | titleBarStyle: 'hiddenInset', 30 | trafficLightPosition: { x: 12, y: 12 }, 31 | webPreferences: { preload }, 32 | }) 33 | 34 | this.id = id 35 | this.#window = window 36 | 37 | // When the window is closed, close all its tabs too. 38 | window.on('closed', () => { 39 | log.info('Received window `closed` event', { id: this.id }) 40 | if (!main.windows[id]) return 41 | 42 | // Trigger a pseudo-event on the tabs. 43 | for (let tab of this.tabs) { 44 | tab.onClosed() 45 | } 46 | 47 | // If this is a deliberate close, remove the window from storage. 48 | if (!main.isQuitting) { 49 | main.change((s) => { 50 | delete s.windows[id] 51 | }) 52 | } 53 | 54 | delete main.windows[id] 55 | }) 56 | 57 | // When the window resizes, update it's bounds and resize its tab view. 58 | window.on('resize', () => { 59 | log.info('Received window `resize` event', { id: this.id }) 60 | let bounds = this.#window.getBounds() 61 | this.change((w) => { 62 | w.x = bounds.x 63 | w.y = bounds.y 64 | w.width = bounds.width 65 | w.height = bounds.height 66 | }) 67 | this.resizeView() 68 | }) 69 | 70 | // When the window moves, update it's position in storage. 71 | window.on('move', () => { 72 | log.info('Received window `move` event', { id: this.id }) 73 | let [x, y] = this.#window.getPosition() 74 | this.change((w) => { 75 | w.x = x 76 | w.y = y 77 | }) 78 | }) 79 | 80 | log.info('Opening window URL…', { url }) 81 | window.loadURL(url) 82 | } 83 | 84 | /** 85 | * Statics. 86 | */ 87 | 88 | /** Create a new `Window`. */ 89 | static create(): Window { 90 | let id = crypto.randomUUID() 91 | main.change((s) => { 92 | s.windows[id] = { 93 | id, 94 | tabIds: [], 95 | activeTabId: null, 96 | x: 0, 97 | y: 0, 98 | width: 1200, 99 | height: 750, 100 | } 101 | }) 102 | 103 | let window = new Window(id) 104 | main.windows[id] = window 105 | return window 106 | } 107 | 108 | /** Restore a saved window by `id`. */ 109 | static restore(id: string): Window { 110 | let w = main.store.windows[id] 111 | if (!w) throw new Error(`Cannot restore unknown window: ${id}`) 112 | 113 | let window = new Window(id) 114 | main.windows[id] = window 115 | 116 | for (let tid of w.tabIds) { 117 | Tab.restore(tid) 118 | } 119 | 120 | if (w.activeTabId) window.activateTab(w.activeTabId) 121 | return window 122 | } 123 | 124 | /** Get all the windows. */ 125 | static all(): Window[] { 126 | return Object.values(main.windows) 127 | } 128 | 129 | /** Get the currently focused window. */ 130 | static byFocused(): Window | null { 131 | let focused = BrowserWindow.getFocusedWindow() 132 | if (!focused) return null 133 | let window = Window.bySenderId(focused.webContents.id) 134 | return window 135 | } 136 | 137 | /** Get the active (or most recently active) window. */ 138 | static byActive(): Window | null { 139 | return Window.byFocused() ?? Window.all()[0] ?? null 140 | } 141 | 142 | /** Get a window by `id`. */ 143 | static byId(id: string): Window { 144 | return main.windows[id] 145 | } 146 | 147 | /** Get a window by `senderId`. */ 148 | static bySenderId(senderId: number): Window { 149 | let window = Window.all().find((w) => w.senderId === senderId) 150 | let tab = Tab.all().find((t) => t.senderId === senderId) 151 | if (!window) window = tab?.window 152 | if (!window) throw new Error(`Cannot find window by sender id: ${senderId}`) 153 | return window 154 | } 155 | 156 | /** 157 | * Getters & setters. 158 | */ 159 | 160 | /** Get the active `Tab` instance of a window. */ 161 | get activeTab(): Tab | null { 162 | let id = this.activeTabId 163 | return id ? Tab.byId(id) : null 164 | } 165 | 166 | /** Get the `activeTabId` of the window. */ 167 | get activeTabId(): string | null { 168 | return main.store.windows[this.id].activeTabId 169 | } 170 | 171 | /** Get the windows bounds. */ 172 | get bounds(): { x: number; y: number; width: number; height: number } { 173 | let { x, y, width, height } = main.store.windows[this.id] 174 | return { x, y, width, height } 175 | } 176 | 177 | /** The electron window's `webContents` id, for IPC messages. */ 178 | get senderId(): number { 179 | return this.#window.webContents.id 180 | } 181 | 182 | /** Get the child `Tab` instances of a window. */ 183 | get tabs(): Tab[] { 184 | return this.tabIds.map((id) => Tab.byId(id)) 185 | } 186 | 187 | /** Get the child `tabIds` of a window. */ 188 | get tabIds(): string[] { 189 | return main.store.windows[this.id].tabIds 190 | } 191 | 192 | /** Set the window's state using an Immer `recipe` function. */ 193 | change(recipe: (draft: Draft) => void): void { 194 | main.change((m) => { 195 | recipe(m.windows[this.id]) 196 | }) 197 | } 198 | 199 | /** 200 | * Actions. 201 | */ 202 | 203 | /** Activate a tab by `id` in the window. */ 204 | activateTab(tabId: string) { 205 | log.info('Activating tab…', { id: this.id, tabId }) 206 | let tab = Tab.byId(tabId) 207 | this.#window.setBrowserView(tab.view) 208 | this.resizeView() 209 | this.change((w) => { 210 | w.activeTabId = tab.id 211 | }) 212 | } 213 | 214 | /** Attach an existing tab to the window. */ 215 | attachTab(tabId: string) { 216 | log.info('Attaching tab…', { id: this.id, tabId }) 217 | this.change((w) => { 218 | if (!w.tabIds.includes(tabId)) { 219 | w.tabIds.push(tabId) 220 | } 221 | }) 222 | } 223 | 224 | /** Close the window. */ 225 | close() { 226 | log.info('Closing window…', { id: this.id }) 227 | this.#window.close() 228 | } 229 | 230 | /** Close one of the window's tabs. */ 231 | closeTab(tabId: string) { 232 | log.info('Closing tab…', { id: this.id, tabId }) 233 | let tab = Tab.byId(tabId) 234 | this.detachTab(tab.id) 235 | tab.onClosed() 236 | } 237 | 238 | /** Detach a tab from the window, without closing it. */ 239 | detachTab(tabId: string) { 240 | log.info('Detaching tab…', { id: this.id, tabId }) 241 | let index = this.tabIds.indexOf(tabId) 242 | if (index == -1) 243 | throw new Error(`Cannot detach tab that isn't attached: ${tabId}`) 244 | 245 | this.change((w) => { 246 | w.tabIds.splice(index, 1) 247 | }) 248 | 249 | let { tabIds } = this 250 | let nextId = tabIds[index] ?? tabIds[index - 1] ?? tabIds[0] 251 | 252 | if (nextId) { 253 | this.activateTab(nextId) 254 | } else { 255 | this.#window.setBrowserView(null) 256 | this.change((w) => { 257 | w.activeTabId = null 258 | }) 259 | } 260 | } 261 | 262 | /** Focus the window. */ 263 | focus() { 264 | log.info('Focusing window…', { id: this.id }) 265 | this.#window.focus() 266 | } 267 | 268 | /** Hide the window. */ 269 | hide() { 270 | log.info('Hiding window…', { id: this.id }) 271 | this.#window.hide() 272 | } 273 | 274 | /** Toggle the developer tools for the window. */ 275 | inspect() { 276 | log.info('Inspecting window…', { id: this.id }) 277 | let w = this.#window.webContents 278 | if (w.isDevToolsOpened()) w.closeDevTools() 279 | w.openDevTools() 280 | } 281 | 282 | /** Open a sketch `path` in a new tab in the window. */ 283 | openTab(path: string): Tab { 284 | log.info('Opening tab…', { id: this.id, path }) 285 | let tab = Tab.create(path) 286 | this.attachTab(tab.id) 287 | this.activateTab(tab.id) 288 | return tab 289 | } 290 | 291 | /** Reload the window. */ 292 | reload() { 293 | log.info('Reloading window…', { id: this.id }) 294 | this.#window.reload() 295 | } 296 | 297 | /** Resize the current tab's view to match the window size. */ 298 | resizeView() { 299 | let view = this.#window.getBrowserView() 300 | if (!view) return 301 | let { width, height } = this.#window.getBounds() 302 | let padding = 40 303 | view.setBounds({ x: 0, y: padding, width, height: height - padding }) 304 | } 305 | 306 | /** Show the window. */ 307 | show() { 308 | log.info('Showing window…', { id: this.id }) 309 | this.#window.setBounds(this.bounds) 310 | this.#window.show() 311 | this.resizeView() // required to keep the view aligned 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/components/editor-console.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { parse } from 'stacktrace-parser' 3 | import { SourceMapConsumer, RawSourceMap } from 'source-map' 4 | import { useEntrypoint } from '../contexts/entrypoint' 5 | import { BuildResult } from 'esbuild' 6 | 7 | type Failure = { 8 | name: string 9 | message: string 10 | frames: Frame[] 11 | } 12 | 13 | type Frame = { 14 | file: string 15 | line: number 16 | column: number 17 | method: string | null 18 | text: string 19 | context: Row[] 20 | original: Omit 21 | } 22 | 23 | type Row = { 24 | line: number 25 | text: string 26 | } 27 | 28 | export let EditorConsole = (props: { error: Error | string | null }) => { 29 | let { error } = props 30 | let [failure, setFailure] = useState(null) 31 | 32 | useEffect(() => { 33 | if (error == null) { 34 | return 35 | } else if (typeof error === 'string') { 36 | resolveBuildFailure(error) 37 | .then((failure) => setFailure(failure)) 38 | .catch((e) => console.error(e)) 39 | } else { 40 | resolveRuntimeFailure(error) 41 | .then((failure) => setFailure(failure)) 42 | .catch((e) => console.error(e)) 43 | } 44 | }, [error]) 45 | 46 | return ( 47 | failure && ( 48 |
49 |
50 |

51 | {failure.name}: 52 | {failure.message} 53 |

54 |
55 | {failure.frames.map((frame) => ( 56 | 60 | ))} 61 |
62 |
63 |
64 | ) 65 | ) 66 | } 67 | 68 | let EditorErrorFrame = (props: { frame: Frame }) => { 69 | let [entrypoint] = useEntrypoint() 70 | let { frame } = props 71 | let { original } = frame 72 | let isSketch = frame.file === entrypoint.url 73 | let pad = Math.max(...original.context.map((c) => c.line.toString().length)) 74 | console.log(frame) 75 | return ( 76 |
77 |
78 | {original.method == null 79 | ? null 80 | : original.method ?? frame.method ?? ''} 81 |
82 |
83 | {original.file}:{original.line}:{original.column} 84 |
85 |
 86 |         
 87 |           {original.context.map((c) => {
 88 |             return (
 89 |               
 96 |             )
 97 |           })}
 98 |         
 99 |       
100 |
101 | ) 102 | } 103 | 104 | let EditorErrorLine = (props: { 105 | row: Row 106 | line: number 107 | column: number 108 | pad: number 109 | }) => { 110 | let { row, line, column, pad } = props 111 | let highlight = row.line === line 112 | return ( 113 |
114 | 120 | {row.line.toString().padStart(pad)} 121 | 122 | 128 | {highlight ? row.text.slice(0, column) : row.text} 129 | 130 | {highlight ? ( 131 | 132 | {row.text.slice(column)} 133 | 134 | ) : null} 135 |
136 | ) 137 | } 138 | 139 | async function resolveBuildFailure(url: string): Promise { 140 | let origin = new URL(url).origin 141 | let req = `${origin}/esbuild-errors` 142 | let res = await fetch(req) 143 | let errors = (await res.json()) as BuildResult['errors'] 144 | let [first] = errors 145 | if (first == null) return null 146 | let loc = first.location 147 | if (!loc) return null 148 | return { 149 | name: 'Error', 150 | message: first.text, 151 | frames: [ 152 | { 153 | file: loc.file ?? '', 154 | line: loc.line ?? 0, 155 | column: loc.column ?? 0, 156 | text: loc.lineText ?? '', 157 | method: null, 158 | context: [ 159 | { 160 | line: loc.line, 161 | text: loc.lineText, 162 | }, 163 | ], 164 | original: { 165 | file: loc.file ?? '', 166 | line: loc.line ?? 0, 167 | column: loc.column ?? 0, 168 | text: loc.lineText ?? '', 169 | method: null, 170 | context: [ 171 | { 172 | line: loc.line, 173 | text: loc.lineText, 174 | }, 175 | ], 176 | }, 177 | }, 178 | ], 179 | } 180 | } 181 | 182 | async function resolveRuntimeFailure(error: Error): Promise { 183 | let frames = await parseFrames(error) 184 | return { 185 | name: error.name, 186 | message: error.message, 187 | frames, 188 | } 189 | } 190 | 191 | async function parseFrames(error: Error): Promise { 192 | let frames = parse(error.stack!) 193 | let files = new Set() 194 | let cache: Record = {} 195 | 196 | // Convert local files to the proper URL scheme. 197 | for (let frame of frames) { 198 | let { file } = frame 199 | if (file != null) { 200 | if ( 201 | file.startsWith('/') || // Posix 202 | /^[a-z]:\\/i.test(file) || // Win32 203 | file.startsWith('\\\\') // Win32 UNC 204 | ) { 205 | file = frame.file = `file://${file}` 206 | } 207 | files.add(file) 208 | } 209 | } 210 | 211 | await Promise.all( 212 | Array.from(files).map(async (file) => { 213 | const text = await fetch(file).then((r) => r.text()) 214 | const sourcemap = await getSourceMap(file, text) 215 | cache[file] = { text, sourcemap } 216 | }) 217 | ) 218 | 219 | let results: Frame[] = [] 220 | 221 | for (let frame of frames) { 222 | let { file, lineNumber: line, column, methodName: method } = frame 223 | if (file == null || line == null || column == null) { 224 | throw new Error('unknown frame!') 225 | } 226 | 227 | // Skip frames above the `Sketch.exec` call in Void's library. 228 | if (method === 'exec' && new URL(file).pathname.endsWith('void.mjs')) { 229 | break 230 | } 231 | 232 | let { text, sourcemap } = cache[file] 233 | let original = sourcemap.originalPositionFor({ line, column }) 234 | let originalText = sourcemap.sourceContentFor(original.source) 235 | results.push({ 236 | file, 237 | line, 238 | column, 239 | method: method === '' ? null : method, 240 | text, 241 | context: getLinesAround(text, line, 3), 242 | original: { 243 | file: original.source, 244 | line: original.line, 245 | column: original.column, 246 | method: original.name ?? null, 247 | text: originalText, 248 | context: getLinesAround(originalText, original.line, 3), 249 | }, 250 | }) 251 | } 252 | 253 | return results 254 | } 255 | 256 | /** Get `n` lines around `line` in `text`. */ 257 | function getLinesAround(text: string, line: number, n: number): Row[] { 258 | let lines = text.split('\n') 259 | let context: { line: number; text: string }[] = [] 260 | let start = Math.max(0, line - 1 - n) 261 | let end = Math.min(lines.length - 1, line - 1 + n) 262 | for (let i = start; i <= end; ++i) { 263 | context.push({ line: i + 1, text: lines[i] }) 264 | } 265 | return context 266 | } 267 | 268 | /** Get a `SourceMapConsumer` from a `file` with `text` content. */ 269 | async function getSourceMap( 270 | file: string, 271 | text: string 272 | ): Promise { 273 | let url = getSourceMapUrl(text) 274 | if (!url) { 275 | throw new Error(`Cannot find a source map directive for ${file}.`) 276 | } 277 | 278 | let raw: RawSourceMap 279 | if (url.indexOf('data:') === 0) { 280 | let base64 = /^data:application\/json;([\w=:"-]+;)*base64,/ 281 | let match2 = url.match(base64) 282 | if (!match2) { 283 | throw new Error( 284 | 'Sorry, non-base64 inline source-map encoding is not supported.' 285 | ) 286 | } 287 | url = url.substring(match2[0].length) 288 | url = window.atob(url) 289 | raw = JSON.parse(url) 290 | } else { 291 | let index = file.lastIndexOf('/') 292 | url = file.substring(0, index + 1) + url 293 | raw = await fetch(url).then((res) => res.json()) 294 | } 295 | 296 | return new SourceMapConsumer(raw) 297 | } 298 | 299 | /** Get the source map URL from a `file` with `text` content. */ 300 | function getSourceMapUrl(text: string): string | null { 301 | const regex = /\/\/[#@] ?sourceMappingURL=([^\s'"]+)\s*$/gm 302 | let match = null 303 | for (;;) { 304 | let next = regex.exec(text) 305 | if (next == null) break 306 | match = next 307 | } 308 | 309 | return match != null && match[1] != null ? match[1].toString() : null 310 | } 311 | -------------------------------------------------------------------------------- /docs/void.md: -------------------------------------------------------------------------------- 1 | # `Void` 2 | 3 | ```ts 4 | import { Void } from 'void' 5 | ``` 6 | 7 | The `Void` namespace holds the tools you use to control a sketch. You use them to size your canvas, generate random values, define custom traits, and wire up interactions. 8 | 9 | Void is fully treeshake-able, so you'll only bundle methods you use. 10 | 11 | - [**Canvas**](#canvas) 12 | - [`Void.draw()`](#voiddraw) 13 | - [`Void.layer()`](#voidlayer) 14 | - [`Void.settings()`](#voidsettings) 15 | - [**Traits**](#traits) 16 | - [`Void.bool()`](#voidbool) 17 | - [`Void.float()`](#voidfloat) 18 | - [`Void.int()`](#voidint) 19 | - [`Void.pick()`](#voidpick) 20 | - [**Interaction**](#interaction) 21 | - [`Void.event()`](#voidevent) 22 | - [`Void.keyboard()`](#voidkeyboard) 23 | - [`Void.pointer()`](#voidpointer) 24 | - [**Utils**](#utils) 25 | - [`Void.convert()`](#voidconvert) 26 | - [`Void.fork()`](#voidfork) 27 | - [`Void.random()`](#voidrandom) 28 | 29 | ## Canvas 30 | 31 | The canvas methods setup your sketch's layout and determine how it's drawn. 32 | 33 | ### `Void.draw()` 34 | 35 | ```ts 36 | Void.draw(callback: () => void) => void 37 | ``` 38 | 39 | Defines the function used to draw each frame in an animated sketch. You aren't _required_ to define a draw function. You could choose to draw everything in a single frame in the sketch's main function instead. 40 | 41 | The drawing frame rate is determined by the `fps` option of [`Void.settings()`](#settings). 42 | 43 | ```ts 44 | export default function () { 45 | let ctx = Void.layer() 46 | let x = 0 47 | let y = 0 48 | Void.draw(() => { 49 | ctx.fillRect(x, y, 100, 150) 50 | x += 5 51 | y += 10 52 | }) 53 | } 54 | ``` 55 | 56 | ### `Void.layer()` 57 | 58 | ```ts 59 | Void.layer(name?: string) => CanvasRenderingContext2D 60 | ``` 61 | 62 | Creates a new layer in the sketch's canvas, on top of any previous layers. 63 | 64 | The returned value is a 2D [`context`](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D) object for the canvas with familiar canvas methods like `fill`, `stroke`, `rect`, `arc`, etc. The context is pre-transformed so that it uses the units you've set with [`Void.settings()`](#settings). So if your canvas is `42 mm` in width, the context will also be `42` units wide. 65 | 66 | ```ts 67 | // Create a new layer. 68 | let bg = Void.layer() 69 | bg.fillRect(50, 50, 100, 100) 70 | 71 | // Create a new layer with a name that will show up in the interface. 72 | let border = Void.layer('border') 73 | border.stokeStyle = 'gray' 74 | border.strokeRect(0, 0, width, height) 75 | ``` 76 | 77 | ### `Void.settings()` 78 | 79 | ```ts 80 | Void.settings(dimensions: Size | [number, number, Units]) => { ... } 81 | Void.settings(options?: { 82 | dimensions?: Size | [number, number, Units] 83 | dpi?: number 84 | fps?: number 85 | frames?: number 86 | margin?: [number, number?, number?, number?, Units] 87 | orientation?: 'portrait' | 'landscape' | 'square' 88 | precision?: [number, Units] 89 | units?: Units 90 | }) => { 91 | dpi: number 92 | fps: number 93 | frames: number 94 | height: number 95 | margin: [number, number, number, number] 96 | precision: number 97 | units: Units 98 | width: number 99 | } 100 | ``` 101 | 102 | Configures the sketch and return a settings object that you can use to tailor your drawings to the width, height, frame rate, dpi, etc. 103 | 104 | The `dimensions` argument can either be a `[number, number, Units]` tuple, a screen size keyword (eg. `1080p`) or a paper size keyword (eg. `A4`). If omitted, the sketch will be fullscreen. 105 | 106 | The `margin` option will be subtracted from `dimensions`, and the returned `width` and `height` will too. This way you don't need to be constantly accounting for the margins in calculations. 107 | 108 | ```ts 109 | // Define a canvas that takes up all the available screen space. 110 | let { width, height } = Void.settings() 111 | 112 | // Define a canvas using the shorthand, that's 300 pixels square. 113 | let { width, height } = Void.settings([300, 300, 'px']) 114 | 115 | // Define a canvas that is the size of an A4 sheet of paper, with 4mm margins, 116 | // in landscape orientation. And use those variables for drawing. 117 | let { width, height, margin } = Void.settings({ 118 | dimensions: 'A4', 119 | margin: [4, 'mm'], 120 | orientation: 'landscape', 121 | }) 122 | 123 | // Define a canvas that's 6 inches square, but using millimeters as the unit 124 | // of measurement. 125 | let { width, height, margin } = Void.settings({ 126 | dimensions: [6, 6, 'in'], 127 | margin: [0.5, 'in'], 128 | units: 'mm', 129 | }) 130 | ``` 131 | 132 | ## Traits 133 | 134 | Traits are special variables that you can define that will appear in the interface along with controls to tweak them. They let you quickly try different variations of ideas as your sketching. 135 | 136 | ### `Void.bool()` 137 | 138 | ```ts 139 | Void.bool(name: string, initial?: boolean) => boolean 140 | Void.bool(name: string, probability?: number) => boolean 141 | ``` 142 | 143 | Defines a boolean trait for the sketch. 144 | 145 | If you don't supply an `initial` argument, the boolean will be randomly generated with a 50/50 chance of being `true`. You can change this by passing in a custom `probability` argument instead. 146 | 147 | ```ts 148 | // Define a boolean trait named "hidden" initially set to `false`. 149 | let hidden = Void.bool('hidden', true) 150 | 151 | // Define a boolean trait named "enabled" that is randomly generated. 152 | let enabled = Void.bool('enabled') 153 | 154 | // The same "enabled" boolean, but with a 25% chance of being true. 155 | let enabled = Void.bool('enabled', 0.25) 156 | ``` 157 | 158 | ### `Void.float()` 159 | 160 | ```ts 161 | Void.float(name: string, initial: number) => number 162 | Void.float(name: string, min: number, max: number, step?: number) => number 163 | ``` 164 | 165 | Defines a floating point number trait for the sketch. 166 | 167 | ```ts 168 | // Define a trait named "multiplier" initially set to `0.5`. 169 | let mul = Void.float('multiplier', 0.5) 170 | 171 | // Define a trait named "ratio" that is randomly generated between `0` and 172 | // `1`, in increments of `0.1`. 173 | let ratio = Void.float('ratio', 0, 1, 0.1) 174 | ``` 175 | 176 | ### `Void.int()` 177 | 178 | ```ts 179 | Void.int(name: string, initial: number) => number 180 | Void.int(name: string, min: number, max: number, step?: number) => number 181 | ``` 182 | 183 | Defines an integer trait for the sketch. 184 | 185 | ```ts 186 | // Define a trait named "columns" initially set to `12`. 187 | let cols = Void.int('columns', 12) 188 | 189 | // Define a trait named "angle" that is randomly generated between `0` and `360`. 190 | let angle = Void.int('angle', 0, 360) 191 | ``` 192 | 193 | ### `Void.pick()` 194 | 195 | ```ts 196 | Void.pick( 197 | name: string, 198 | initial?: string, 199 | choices: T[] | [number, T][] | Record | Record 200 | ) => T 201 | ``` 202 | 203 | Defines an enum trait for the sketch, choosing one of many values. 204 | 205 | When defining weighted choices the weights do not need to add to `1`. They will be determined relative to the sum of all weight values. 206 | 207 | ```ts 208 | // Define a trait named "color" that is randomly chosen from a set of options. 209 | let color = Void.pick('color', [ 210 | '#b8baaa', 211 | '#ac7458', 212 | '#1a1211', 213 | '#f1ce48', 214 | '#87ac5d', 215 | ]) 216 | 217 | // The same "color" trait from above, but each of the choices has a weight 218 | // associated with it, determining the probably with which it'll be chosen. 219 | let color = Void.pick('color', [ 220 | [1, '#b8baaa'], 221 | [3, '#ac7458'], 222 | [2, '#1a1211'], 223 | [5, '#f1ce48'], 224 | [2, '#87ac5d'], 225 | ]) 226 | 227 | // Define a trait "ease" that is randomly chosen from a set of functions. 228 | let ease = Void.pick('ease', { 229 | bounce: (t) => { ... }, 230 | linear: (t) => { ... }, 231 | smooth: (t) => { ... }, 232 | quad: (t) => { ... }, 233 | }) 234 | 235 | // Define a trait named "density" initially set to "normal", but that can be 236 | // changed to one of three values. 237 | let density = Void.pick('density', 'normal', [ 238 | 'compact', 239 | 'normal', 240 | 'sparse', 241 | ]) 242 | ``` 243 | 244 | ## Interaction 245 | 246 | These methods help manage user interaction while your sketch is running. 247 | 248 | ### `Void.event()` 249 | 250 | ```ts 251 | Void.event(type: string, callback: (e: event) => void) => () => void 252 | ``` 253 | 254 | Listens for a DOM event of `type`. This is the most flexible of the interaction methods, and lets you listen to any DOM events you'd like to create complex interactions. 255 | 256 | The return value is an `unsubscribe` function which you can call if you want to stop listening at any point. However it will be automatically called when your sketch ends, so you don't need to worry about cleanup. 257 | 258 | ```ts 259 | Void.event('dblclick', (e) => { 260 | ctx.lineWidth++ 261 | }) 262 | ``` 263 | 264 | ### `Void.keyboard()` 265 | 266 | ```ts 267 | Void.keyboard() => { 268 | code: string | null 269 | codes: Record 270 | key: string | null 271 | keys: Record 272 | } 273 | ``` 274 | 275 | Information about which keys are pressed on the keyboard, based on the DOM's [Keyboard events](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent). 276 | 277 | - `code` - the [`code`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code) value of the key being pressed. 278 | - `codes` - a dictionary with the [`code`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code) values for every key currently being pressed. 279 | - `key` - the [`key`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key) value of the key being pressed. 280 | - `keys` - a dictionary with the [`key`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key) values for every key currently being pressed. 281 | 282 | These properties will reflect the key in question as long as it continues to be held down. So for example, you could make a sketch that drew in a different color each frame if the space bar was held down. 283 | 284 | If you'd instead like to perform a single action when a key is first pressed, using the [`Void.event()`](#event) method instead. 285 | 286 | ```ts 287 | let keyboard = Void.keyboard() 288 | ctx.fillStyle = keyboard.keys.Enter ? 'red' : 'black' 289 | ``` 290 | 291 | ### `Void.pointer()` 292 | 293 | ```ts 294 | Void.pointer() => { 295 | type: 'mouse' | 'pen' | 'touch' | null 296 | x: number | null 297 | y: number | null 298 | position: [number, number] | null 299 | button: number | null 300 | buttons: Record 301 | } 302 | ``` 303 | 304 | Information about where the pointer is (eg. mouse, stylus, finger) relative to the canvas, and which buttons are pressed, based on the DOM's [Pointer events](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent). 305 | 306 | - `type` - the type of the pointer. 307 | - `x` - the X-axis position of the pointer. 308 | - `y` - the Y-axis position of the pointer. 309 | - `position` - the position of the pointer as a vector tuple. 310 | - `button` - a number representing the mouse/stylus [`button`](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button) being pressed. 311 | - `buttons` - a dictionary representing every mouse/stylus [`button`](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button) currently being pressed. 312 | 313 | All position-related properties are expressed in the same dimensions and units you setup your sketch to use, so you don't need to worry about converting the absolute values to relative units. 314 | 315 | ```ts 316 | let pointer = Void.pointer() 317 | ctx.fillStyle = pointer.x > width / 2 ? 'red' : 'transparent' 318 | ``` 319 | 320 | ## Utils 321 | 322 | ### `Void.convert()` 323 | 324 | ```ts 325 | Void.convert(x: number, from: Units, to?: Units, options?: { 326 | dpi?: number 327 | precision?: number 328 | }) => number 329 | ``` 330 | 331 | Converts a value `x` defined in `from` units to the equivalent in `to` units. 332 | 333 | When the `to` argument is omitted, Void will default it to the units of the current canvas. So this gives you an easy way to quickly get canvas-relative units from real-world ones. 334 | 335 | ```ts 336 | // Set the line width to 4 millimeters, regardless of canvas resolution. 337 | ctx.lineWidth = Void.convert(4, 'mm') 338 | ``` 339 | 340 | ### `Void.fork()` 341 | 342 | ```ts 343 | Void.fork(callback: () => T) => T 344 | ``` 345 | 346 | Runs a `callback` function with a fork of the current random seed, so that you can retrieve any amount of random numbers inside the callback and still only consume a single iteration of your seed. 347 | 348 | This is helpful in situations where you want to conditionally do some logic that requires randomness, but you want random values _after_ that logic to remain deterministic. 349 | 350 | ```ts 351 | let count = Random.int(1, 4) 352 | let booleans = [] 353 | 354 | // Without this fork wrapper, the loop inside this function would have ticked 355 | // the random seed forward between `1` and `4` times. But with the fork, it 356 | // only ticks forward once, so any random values after it remain deterministic. 357 | Random.fork(() => { 358 | for (let i = 0; i < count; i++) { 359 | booleans.push(Random.bool()) 360 | } 361 | }) 362 | 363 | // Because of the fork above, this boolean stays deterministic. 364 | let after = Random.bool() 365 | ``` 366 | 367 | ### `Void.random()` 368 | 369 | ```ts 370 | Void.random() => number 371 | Void.random(min: number, max: number, step?: number) => number 372 | ``` 373 | 374 | Returns a random number, by default from `0` (inclusive) to `1` (exclusive). 375 | 376 | This is just like `Math.random` except that the randomness is determined by the sketch's seed value, so the same values will be produced every time for the same seed. 377 | 378 | You can also pass the `min`, `max`, and optional `step` arguments to have the result in a different range, and optionally rounded to that step multiple. Both `min` and `max` are inclusive when a `step` is provided. (Without a step technically `max` is exclusive, but the increments are so small that that's usually an implementation detail.) 379 | 380 | ```ts 381 | Void.random() 382 | // 0.384037... 383 | 384 | Void.random(0, 5) 385 | // 2.084939... 386 | 387 | Void.random(1, 7, 1) 388 | // 3 389 | 390 | Void.random(-1, 1, 0.5) 391 | // -0.5 392 | ``` 393 | -------------------------------------------------------------------------------- /packages/void/src/sketch/methods.ts: -------------------------------------------------------------------------------- 1 | import { Units, Config, Schema } from '..' 2 | import { Frame, Handlers, Keyboard, Layer, Pointer, Sketch } from '.' 3 | import { 4 | applyOrientation, 5 | convertUnits, 6 | Sfc32, 7 | CSS_DPI, 8 | svgDataUriToString, 9 | svgElementToString, 10 | svgStringToDataUri, 11 | svgStringToElement, 12 | SVG_NAMESPACE, 13 | } from '../utils' 14 | 15 | /** A dictionary of output mime types. */ 16 | const OUTPUT_MIME_TYPES: Record = { 17 | png: 'image/png', 18 | jpg: 'image/jpeg', 19 | webp: 'image/webp', 20 | svg: 'image/svg+xml', 21 | pdf: 'application/pdf', 22 | } 23 | 24 | /** Get the current sketch and assert one exists. */ 25 | export function assert(): Sketch { 26 | let sketch = current() 27 | if (!sketch) { 28 | throw new Error( 29 | 'Could not find your sketch! You must call `Void.*` methods inside a sketch function!' 30 | ) 31 | } 32 | return sketch 33 | } 34 | 35 | /** Attach the sketch to the DOM. */ 36 | export function attach(sketch: Sketch): void { 37 | let { container, el } = sketch 38 | let [width, height] = dimensions(sketch, 'pixel') 39 | container.style.position = 'relative' 40 | el.style.position = 'absolute' 41 | el.style.width = `${width}px` 42 | el.style.height = `${height}px` 43 | el.style.top = '50%' 44 | el.style.left = '50%' 45 | el.style.margin = `${-height / 2}px 0 0 ${-width / 2}px` 46 | el.style.background = 'white' 47 | el.style.outline = '1px solid #e4e4e4' 48 | container.appendChild(el) 49 | } 50 | 51 | /** Get the current sketch from the global `VOID` context. */ 52 | export function current(): Sketch | undefined { 53 | return globalThis.VOID?.sketch 54 | } 55 | 56 | /** Detach the sketch from the DOM. */ 57 | export function detach(sketch: Sketch): void { 58 | let { container, el } = sketch 59 | stop(sketch) 60 | if (el.parentNode === container) container.removeChild(el) 61 | } 62 | 63 | /** Get the full-sized dimensions of a `sketch`, including margins, in the sketch's own units. */ 64 | export function dimensions( 65 | sketch: Sketch, 66 | mode: 'sketch' | 'pixel' | 'device' = 'sketch' 67 | ): [number, number] { 68 | let { settings } = sketch 69 | let { width, height, margin, units } = settings 70 | let [top, right, bottom, left] = margin 71 | let precision = 1 72 | let to: Units = mode === 'sketch' ? settings.units : 'px' 73 | let dpi = 74 | mode === 'pixel' 75 | ? CSS_DPI 76 | : mode === 'device' 77 | ? CSS_DPI * window.devicePixelRatio 78 | : settings.dpi 79 | let w = width + left + right 80 | let h = height + top + bottom 81 | let x = convertUnits(w, units, to, { dpi, precision }) 82 | let y = convertUnits(h, units, to, { dpi, precision }) 83 | return [x, y] 84 | } 85 | 86 | /** Emit an `event` to all the sketch's handlers. */ 87 | export function emit( 88 | sketch: Sketch, 89 | event: T, 90 | ...args: Parameters 91 | ): void { 92 | for (let callback of sketch.handlers?.[event] ?? []) { 93 | callback(...(args as [any])) 94 | } 95 | } 96 | 97 | /** Execute a `fn` with the sketch loaded on the global `VOID` context. */ 98 | export function exec(sketch: Sketch, fn: () => void) { 99 | let VOID = (globalThis.VOID ??= {}) 100 | let prev = VOID.sketch 101 | VOID.sketch = sketch 102 | try { 103 | fn() 104 | } catch (e) { 105 | if (sketch.handlers?.error.length) { 106 | Sketch.emit(sketch, 'error', e as Error) 107 | } else { 108 | throw e 109 | } 110 | } finally { 111 | VOID.sketch = prev 112 | } 113 | } 114 | 115 | /** Run a `fn` with a fork of the PRNG, consuming only one random value. */ 116 | export function fork(sketch: Sketch, fn: () => T): T { 117 | let p = prng(sketch) 118 | sketch.prng = Sfc32(p(), p(), p(), p()) 119 | let ret = fn() 120 | sketch.prng = p 121 | return ret 122 | } 123 | 124 | /** Get the sketch's current frame information. */ 125 | export function frame(sketch: Sketch): Frame { 126 | return (sketch.frame ??= { 127 | count: -1, 128 | time: window.performance.now(), 129 | rate: sketch.settings.fps, 130 | }) 131 | } 132 | 133 | /** Get the sketch's current keyboard information. */ 134 | export function keyboard(sketch: Sketch): Keyboard { 135 | return (sketch.keyboard ??= { 136 | key: null, 137 | keys: {}, 138 | code: null, 139 | codes: {}, 140 | }) 141 | } 142 | 143 | /** Create a new layer with `name`. */ 144 | export function layer(sketch: Sketch, name?: string): Layer { 145 | if (!name) { 146 | let { length } = Object.keys(sketch.layers) 147 | while ((name = `Layer ${++length}`) in sketch.layers) {} 148 | } 149 | 150 | // Delete and reassign existing layers to preserve the sketch's layer order. 151 | let layer: Layer = sketch.layers[name] ?? { hidden: false } 152 | if (name in sketch.layers) delete sketch.layers[name] 153 | sketch.layers[name] = layer 154 | return layer 155 | } 156 | 157 | /** Create a sketch from a `construct` function, with optional `el` and `overrides`. */ 158 | export function of(attrs: { 159 | construct: () => void 160 | container: HTMLElement 161 | el?: HTMLElement 162 | hash: string 163 | layers?: Sketch['layers'] 164 | config?: Sketch['config'] 165 | traits?: Sketch['traits'] 166 | output?: Sketch['output'] 167 | }): Sketch { 168 | let { 169 | construct, 170 | container, 171 | el, 172 | hash, 173 | output = { type: 'png' }, 174 | config = {}, 175 | layers = {}, 176 | traits = {}, 177 | } = attrs 178 | 179 | if (el == null) { 180 | if (typeof document !== 'undefined') { 181 | el = document.createElement('div') 182 | } else { 183 | throw new Error('Must have `document` global present to create a sketch!') 184 | } 185 | } 186 | 187 | return { 188 | config, 189 | construct, 190 | container, 191 | el, 192 | hash, 193 | layers, 194 | output, 195 | settings: { 196 | dpi: CSS_DPI, 197 | fps: 60, 198 | frames: Infinity, 199 | height: container.offsetHeight, 200 | margin: [0, 0, 0, 0], 201 | precision: 0, 202 | units: 'px', 203 | width: container.offsetWidth, 204 | }, 205 | traits, 206 | } 207 | } 208 | 209 | /** Attach a `callback` to when an `event` is emitted. */ 210 | export function on( 211 | sketch: Sketch, 212 | event: T, 213 | callback: Handlers[T][number] 214 | ): void { 215 | sketch.handlers ??= { 216 | construct: [], 217 | draw: [], 218 | error: [], 219 | play: [], 220 | pause: [], 221 | stop: [], 222 | } 223 | sketch.handlers[event].push(callback as any) 224 | } 225 | 226 | /** Play the sketch's draw loop. */ 227 | export function play(sketch: Sketch) { 228 | if (sketch.raf) return 229 | if (sketch.status === 'stopped') return 230 | 231 | let { status, settings } = sketch 232 | let frame = Sketch.frame(sketch) 233 | 234 | if (status !== 'playing') { 235 | sketch.status = 'playing' 236 | Sketch.emit(sketch, 'play') 237 | } 238 | 239 | if (status == null) { 240 | Sketch.exec(sketch, sketch.construct) 241 | Sketch.emit(sketch, 'construct') 242 | Sketch.attach(sketch) 243 | } 244 | 245 | if (!sketch.draw || frame.count >= settings.frames) { 246 | Sketch.stop(sketch) 247 | return 248 | } 249 | 250 | let target = 1000 / settings.fps 251 | let now = window.performance.now() 252 | let delta = frame.count < 0 ? target : now - frame.time 253 | let epsilon = 5 254 | if (delta >= target - epsilon) { 255 | frame.count++ 256 | frame.time = now 257 | frame.rate = 1000 / delta 258 | Sketch.exec(sketch, sketch.draw) 259 | Sketch.emit(sketch, 'draw') 260 | } 261 | 262 | sketch.raf = window.requestAnimationFrame(() => { 263 | delete sketch.raf 264 | Sketch.play(sketch) 265 | }) 266 | } 267 | 268 | /** Pause the sketch's draw loop. */ 269 | export function pause(sketch: Sketch) { 270 | if (sketch.status !== 'playing') return 271 | if (sketch.raf) window.cancelAnimationFrame(sketch.raf) 272 | delete sketch.raf 273 | sketch.status = 'paused' 274 | Sketch.emit(sketch, 'pause') 275 | } 276 | 277 | /** Get the sketch's current pointer information. */ 278 | export function pointer(sketch: Sketch): Pointer { 279 | return (sketch.pointer ??= { 280 | type: null, 281 | x: null, 282 | y: null, 283 | position: null, 284 | button: null, 285 | buttons: {}, 286 | }) 287 | } 288 | 289 | /** Get the sketch's seeded pseudo-random number generator. */ 290 | export function prng(sketch: Sketch): () => number { 291 | return (sketch.prng ??= Sfc32( 292 | ...([0, 1, 2, 3].map((n) => { 293 | let i = 2 + n * 8 294 | return parseInt(sketch.hash.substring(i, i + 8), 16) 295 | }) as [number, number, number, number]) 296 | )) 297 | } 298 | 299 | /** Save the sketch's layers as an image. */ 300 | export async function save(sketch: Sketch): Promise { 301 | if (!sketch.output) { 302 | throw new Error(`Cannot save a sketch that wasn't initialized for export!`) 303 | } 304 | 305 | switch (sketch.output.type) { 306 | case 'png': 307 | case 'jpg': 308 | case 'webp': 309 | return await saveImage(sketch) 310 | case 'svg': 311 | return await saveSvg(sketch) 312 | case 'pdf': 313 | throw new Error('not implemented!') 314 | } 315 | } 316 | 317 | export async function saveImage(sketch: Sketch): Promise { 318 | let canvas = document.createElement('canvas') 319 | let context = canvas.getContext('2d') 320 | if (!context) { 321 | throw new Error(`Cannot get a 2D context for a canvas!`) 322 | } 323 | 324 | let [deviceWidth, deviceHeight] = Sketch.dimensions(sketch, 'device') 325 | let [pixelHeight, pixelWidth] = Sketch.dimensions(sketch, 'pixel') 326 | canvas.width = deviceWidth 327 | canvas.height = deviceHeight 328 | canvas.style.width = `${pixelHeight}px` 329 | canvas.style.height = `${pixelWidth}px` 330 | 331 | let images = await Promise.all( 332 | Object.values(sketch.layers) 333 | .filter((layer) => layer.export != null) 334 | .map((layer) => { 335 | return new Promise((resolve, reject) => { 336 | let img = new Image() 337 | let url = layer.export!() 338 | img.onload = () => resolve(img) 339 | img.onerror = (e, source, lineno, colno, error) => reject(error) 340 | img.src = url 341 | }) 342 | }) 343 | ) 344 | 345 | for (let image of images) { 346 | context.drawImage(image, 0, 0) 347 | } 348 | 349 | let { output } = sketch 350 | let { type } = output 351 | let quality = 'quality' in output ? output.quality : 1 352 | let mime = OUTPUT_MIME_TYPES[type] 353 | let url = canvas.toDataURL(mime, quality) 354 | return url 355 | } 356 | 357 | export async function saveSvg(sketch: Sketch): Promise { 358 | let svg = document.createElementNS(SVG_NAMESPACE, 'svg') as SVGSVGElement 359 | 360 | for (let layer of Object.values(sketch.layers)) { 361 | if (!layer.export) continue 362 | let url = layer.export() 363 | let string = svgDataUriToString(url) 364 | let el = svgStringToElement(string) 365 | let group = document.createElementNS(SVG_NAMESPACE, 'g') 366 | svg.setAttribute('version', el.getAttribute('version')!) 367 | svg.setAttribute('xmlns', el.getAttribute('xmlns')!) 368 | svg.setAttribute('xmlns:xlink', el.getAttribute('xmlns:xlink')!) 369 | svg.setAttribute('width', el.getAttribute('width')!) 370 | svg.setAttribute('height', el.getAttribute('height')!) 371 | svg.appendChild(group) 372 | for (let node of Array.from(el.childNodes)) { 373 | group.appendChild(node) 374 | } 375 | } 376 | 377 | let string = svgElementToString(svg) 378 | let url = svgStringToDataUri(string) 379 | return url 380 | } 381 | 382 | // Code from before: 383 | // import { jsPDF } from 'jspdf' 384 | // export async function savePdf(sketch: Sketch): Promise { 385 | // /** Export a vector PDF file from a `settings` and `module`. */ 386 | // export let exportPdf = async ( 387 | // module: Module, 388 | // settings: Settings, 389 | // traits: Traits 390 | // ) => { 391 | // let string = getSvg(module, settings, traits) 392 | // let div = document.createElement('div') 393 | // div.innerHTML = string 394 | // let el = div.firstChild as SVGSVGElement 395 | // let [width, height] = Settings.outerDimensions(settings) 396 | // let { units } = settings 397 | // let doc = new jsPDF({ 398 | // unit: units as any, 399 | // format: [width, height], 400 | // hotfixes: ['px_scaling'], 401 | // }) 402 | // await doc.svg(el, { width, height, x: 0, y: 0 }) 403 | // doc.save('download.pdf') 404 | // } 405 | // } 406 | 407 | /** Resolve a `config` object into the sketch's settings. */ 408 | export function settings(sketch: Sketch, config: Config): Sketch['settings'] { 409 | config = { ...config, ...sketch.config } 410 | let { dpi = CSS_DPI, fps = 60, frames = Infinity } = config 411 | let orientation = Config.orientation(config) 412 | let units = Config.units(config) 413 | 414 | // Convert the precision to the sketch's units. 415 | let [precision, pu] = Config.precision(config) 416 | precision = convertUnits(precision, pu, units, { dpi }) 417 | 418 | // Create a unit conversion helper with the sketch's default units. 419 | let [width, height, du] = Config.dimensions(config) 420 | if (width === Infinity) width = sketch.container.offsetWidth 421 | if (height === Infinity) height = sketch.container.offsetHeight 422 | width = convertUnits(width, du, units, { dpi, precision }) 423 | height = convertUnits(height, du, units, { dpi, precision }) 424 | 425 | // Apply the orientation setting to the dimensions. 426 | if (orientation != null) { 427 | ;[width, height] = applyOrientation(width, height, orientation) 428 | } 429 | 430 | // Apply a margin, so the canvas is drawn without need to know it. 431 | let [mt, mr, mb, ml, mu] = Config.margin(config) 432 | mt = convertUnits(mt, mu, units, { dpi, precision }) 433 | mr = convertUnits(mr, mu, units, { dpi, precision }) 434 | mb = convertUnits(mb, mu, units, { dpi, precision }) 435 | ml = convertUnits(ml, mu, units, { dpi, precision }) 436 | width -= mr + ml 437 | height -= mt + mb 438 | let margin = [mt, mr, mb, ml] as [number, number, number, number] 439 | 440 | sketch.config = config 441 | sketch.settings = { 442 | dpi, 443 | fps, 444 | frames, 445 | height, 446 | margin, 447 | precision: precision ?? null, 448 | units, 449 | width, 450 | } 451 | return sketch.settings 452 | } 453 | 454 | /** Stop the sketch's draw loop. */ 455 | export function stop(sketch: Sketch) { 456 | if (sketch.status === 'stopped') return 457 | if (sketch.raf) window.cancelAnimationFrame(sketch.raf) 458 | delete sketch.raf 459 | sketch.status = 'stopped' 460 | Sketch.emit(sketch, 'stop') 461 | } 462 | 463 | /** Set a trait on a `sketch` to a new value. */ 464 | export function trait( 465 | sketch: Sketch, 466 | name: string, 467 | value: any, 468 | schema: Schema 469 | ): void { 470 | sketch.schemas ??= {} 471 | sketch.traits[name] = value 472 | sketch.schemas[name] = schema 473 | } 474 | -------------------------------------------------------------------------------- /packages/void/src/void.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'svgcanvas' 2 | import { convertUnits, svgStringToDataUri } from './utils' 3 | import { Sketch, Config, Pointer, Keyboard, Units } from '.' 4 | 5 | // A reference to whether the keyboard event listeners have been attached. 6 | let KEYBOARD_EVENTS: WeakMap = new WeakMap() 7 | 8 | // A reference to whether the pointer event listeners have been attached. 9 | let POINTER_EVENTS: WeakMap = new WeakMap() 10 | 11 | // A type that can be converted to a stringified name easily. 12 | type Nameable = string | number | boolean | null 13 | 14 | // Convert a type to a `Nameable` type. 15 | type Nameify = T extends Nameable ? T : string 16 | 17 | // A type that when used in generics allows narrowing of return values. 18 | type Narrowable = 19 | | string 20 | | number 21 | | bigint 22 | | boolean 23 | | symbol 24 | | object 25 | | undefined 26 | | void 27 | | null 28 | | {} 29 | 30 | /** 31 | * Defines a boolean trait. 32 | * 33 | * If you pass an `initial` value, it will always be used as the default value 34 | * for the trait in a sketch, until overriden. 35 | * 36 | * Other, when randomly generated, you can pass a `probability` which is the 37 | * chance that the boolean will be `true`. 38 | */ 39 | export function bool(name: string, probability?: number): boolean 40 | export function bool(name: string, initial: boolean): boolean 41 | export function bool(name: string, options?: boolean | number): boolean { 42 | const sketch = Sketch.assert() 43 | const { traits } = sketch 44 | const probability = typeof options === 'number' ? options : 0.5 45 | const initial = typeof options === 'boolean' ? options : undefined 46 | const r = random() 47 | let value 48 | 49 | if (name in traits && typeof traits[name] === 'boolean') { 50 | value = traits[name] 51 | } else if (initial != null) { 52 | value = initial 53 | } else { 54 | value = r < probability 55 | } 56 | 57 | Sketch.trait(sketch, name, value, { type: 'boolean', probability, initial }) 58 | return value 59 | } 60 | 61 | /** 62 | * Convert a `value` from one unit to another. 63 | * 64 | * By default this uses the sketch's units as the output units, but that can be 65 | * overriden with the third `to` argument. 66 | */ 67 | export function convert(value: number, from: Units, to?: Units): number 68 | export function convert( 69 | value: number, 70 | from: Units, 71 | options: { 72 | dpi?: number 73 | precision?: number 74 | } 75 | ): number 76 | export function convert( 77 | value: number, 78 | from: Units, 79 | to: Units, 80 | options: { 81 | dpi?: number 82 | precision?: number 83 | } 84 | ): number 85 | export function convert( 86 | value: number, 87 | from: Units, 88 | to?: Units | { dpi?: number; precision?: number }, 89 | options: { dpi?: number; precision?: number } = {} 90 | ): number { 91 | if (typeof to === 'object') (options = to), (to = undefined) 92 | const sketch = Sketch.current() 93 | to = to ?? sketch?.settings.units ?? 'px' 94 | const { dpi = sketch?.settings.dpi, precision } = options 95 | if (from === to) return value 96 | return convertUnits(value, from, to, { dpi, precision }) 97 | } 98 | 99 | /** 100 | * Define a `draw` function with the drawing logic to render each frame of an 101 | * animated sketch. 102 | */ 103 | export function draw(fn: () => void) { 104 | const sketch = Sketch.assert() 105 | sketch.draw = fn 106 | } 107 | 108 | /** 109 | * Attach an event listener to the canvas. 110 | */ 111 | export function event( 112 | event: E, 113 | callback: (e: GlobalEventHandlersEventMap[E]) => void 114 | ): () => void { 115 | const sketch = Sketch.assert() 116 | const { el } = sketch 117 | const fn = (e: GlobalEventHandlersEventMap[E]) => { 118 | Sketch.exec(sketch, () => callback(e)) 119 | } 120 | 121 | el.addEventListener(event, fn) 122 | const off = () => el.removeEventListener(event, fn) 123 | Sketch.on(sketch, 'stop', off) 124 | return off 125 | } 126 | 127 | /** 128 | * Define a floating point trait. 129 | * 130 | * You can either pass a single `initial` argument which will be used as the 131 | * default value. Or you can pass a `min`, `max`, and optional `step` and the 132 | * trait will be randomly generated. 133 | */ 134 | export function float(name: string, initial: number): number 135 | export function float( 136 | name: string, 137 | min: number, 138 | max: number, 139 | step?: number 140 | ): number 141 | export function float( 142 | name: string, 143 | min: number, 144 | max?: number, 145 | step?: number 146 | ): number { 147 | const sketch = Sketch.assert() 148 | const { traits } = sketch 149 | let initial 150 | if (max == null) { 151 | initial = min 152 | min = Number.MIN_VALUE 153 | max = Number.MAX_VALUE 154 | } 155 | 156 | let value = random(min, max, step) 157 | if (name in traits && typeof traits[name] === 'number') { 158 | value = traits[name] 159 | } else if (initial != null) { 160 | value = initial 161 | } 162 | 163 | Sketch.trait(sketch, name, value, { type: 'float', min, max, step, initial }) 164 | return value 165 | } 166 | 167 | /** 168 | * Run a function `fn` with a fork of the current pseudo-random number generator 169 | * seed, so that it only consumes one random value. 170 | */ 171 | export function fork(fn: () => T): T { 172 | const sketch = Sketch.assert() 173 | return Sketch.fork(sketch, fn) 174 | } 175 | 176 | /** 177 | * Define an integer trait. 178 | * 179 | * You can either pass a single `initial` argument which will be used as the 180 | * default value. Or you can pass a `min`, `max`, and optional `step` and the 181 | * trait will be randomly generated. 182 | */ 183 | export function int(name: string, initial: number): number 184 | export function int( 185 | name: string, 186 | min: number, 187 | max: number, 188 | step?: number 189 | ): number 190 | export function int( 191 | name: string, 192 | min: number, 193 | max?: number, 194 | step?: number 195 | ): number { 196 | step = step == null ? 1 : Math.trunc(step) 197 | const sketch = Sketch.assert() 198 | const { traits } = sketch 199 | let initial 200 | if (max == null) { 201 | initial = min 202 | min = Number.MIN_SAFE_INTEGER 203 | max = Number.MAX_SAFE_INTEGER 204 | } 205 | 206 | let value = random(min, max, step) 207 | if (name in traits && typeof traits[name] === 'number') { 208 | value = traits[name] 209 | } else if (initial != null) { 210 | value = initial 211 | } 212 | 213 | Sketch.trait(sketch, name, value, { type: 'int', min, max, step, initial }) 214 | return value 215 | } 216 | 217 | /** 218 | * Get a reference to the current keyboard data. 219 | * 220 | * The returned object is mutable and will continue to stay up to date as keys 221 | * are pressed down and lifted up. 222 | */ 223 | export function keyboard(): Keyboard { 224 | const sketch = Sketch.assert() 225 | const keyboard = Sketch.keyboard(sketch) 226 | 227 | if (!KEYBOARD_EVENTS.has(sketch)) { 228 | event('keydown', (e) => { 229 | keyboard.code = e.code 230 | keyboard.key = e.key 231 | keyboard.codes[e.code] = true 232 | keyboard.keys[e.key] = true 233 | }) 234 | 235 | event('keyup', (e) => { 236 | keyboard.code = null 237 | keyboard.key = null 238 | delete keyboard.codes[e.code] 239 | delete keyboard.keys[e.key] 240 | }) 241 | 242 | KEYBOARD_EVENTS.set(sketch, true) 243 | } 244 | 245 | return keyboard 246 | } 247 | 248 | /** 249 | * Get a new canvas layer to draw on, placed above all other layers. 250 | * 251 | * If the `name` argument is omitted it will be auto-generated. 252 | */ 253 | export function layer(name?: string): CanvasRenderingContext2D { 254 | const sketch = Sketch.assert() 255 | const { settings, output, el } = sketch 256 | const { width, height, margin, units } = settings 257 | const canvas = document.createElement('canvas') 258 | const vector = output.type === 'svg' || output.type === 'pdf' 259 | const ctx = vector 260 | ? new Context(`${width}${units}`, `${height}${units}`) 261 | : canvas.getContext('2d') 262 | if (!ctx) { 263 | throw new Error(`Unable to get 2D rendering context from canvas!`) 264 | } 265 | 266 | const [top, , , left] = margin 267 | const [totalWidth, totalHeight] = Sketch.dimensions(sketch) 268 | const [pixelWidth, pixelHeight] = Sketch.dimensions(sketch, 'pixel') 269 | const [deviceWidth, deviceHeight] = Sketch.dimensions(sketch, 'device') 270 | const layer = Sketch.layer(sketch, name) 271 | canvas.width = deviceWidth 272 | canvas.height = deviceHeight 273 | canvas.style.position = 'absolute' 274 | canvas.style.display = layer.hidden ? 'none' : 'block' 275 | canvas.style.width = `${pixelWidth}px` 276 | canvas.style.height = `${pixelHeight}px` 277 | el.appendChild(canvas) 278 | ctx.scale(deviceWidth / totalWidth, deviceHeight / totalHeight) 279 | ctx.translate(top, left) 280 | layer.export = () => { 281 | return vector 282 | ? svgStringToDataUri(ctx.getSerializedSvg()) 283 | : canvas.toDataURL('image/png') 284 | } 285 | 286 | return ctx 287 | } 288 | 289 | /** 290 | * Define a trait that picks one of many `choices`. 291 | * 292 | * You may pass an `initial` value as the second argument. Otherwise, the choice 293 | * will be randomly picked for you. 294 | * 295 | * The choices may either be an array or object of values. For weighted choices, 296 | * the array or object values should be a tuple, where the first element is a 297 | * number representing the weight. 298 | */ 299 | export function pick(name: string, choices: Choices): V 300 | export function pick(name: string, choices: Choices): V 301 | export function pick( 302 | name: string, 303 | initial: Nameify, 304 | choices: Choices 305 | ): V 306 | export function pick( 307 | name: string, 308 | initial: Nameify, 309 | choices: Choices 310 | ): V 311 | export function pick( 312 | name: string, 313 | initial: Nameify | Choices | undefined, 314 | choices?: Choices 315 | ): V { 316 | if (choices == null) (choices = initial as Choices), (initial = undefined) 317 | const sketch = Sketch.assert() 318 | const { traits } = sketch 319 | const { names, weights, mapping } = normalizeChoices(choices!) 320 | const r = random() 321 | let value 322 | 323 | if (name in traits) { 324 | if (traits[name] in mapping) { 325 | value = traits[name] 326 | } else { 327 | throw new Error(`Cannot re-pick traits "${name}"!`) 328 | } 329 | } else if (initial !== undefined) { 330 | value = String(initial) // allow booleans, numbers, etc. to be real 331 | } else { 332 | const sum = weights.reduce((m, w) => m + w, 0) 333 | const threshold = r * sum 334 | let current = 0 335 | const i = weights.findIndex((weight) => threshold < (current += weight)) 336 | value = names[i] 337 | } 338 | 339 | Sketch.trait(sketch, name, value, { type: 'pick', names, weights }) 340 | return mapping[value] 341 | } 342 | 343 | /** 344 | * Get a reference to the current pointer (eg. mouse, pen, finger) data. 345 | * 346 | * The returned object is mutable and will continue to stay up to date as the 347 | * viewer clicks, taps, or hovers. 348 | * 349 | * Note that the pointer only refers to the "primary" pointer, and to handle 350 | * multi-touch scenarios you'll need to attach your own event handlers with the 351 | * `Void.event()` method instead. 352 | */ 353 | export function pointer(): Pointer { 354 | const sketch = Sketch.assert() 355 | const pointer = Sketch.pointer(sketch) 356 | 357 | if (!POINTER_EVENTS.has(sketch)) { 358 | event('pointermove', (e) => { 359 | if (!e.isPrimary) return 360 | const { el } = sketch 361 | const canvas = el.querySelector('canvas') 362 | if (!canvas) return 363 | pointer.type = e.pointerType as 'mouse' | 'pen' | 'touch' 364 | pointer.position ??= [] as any 365 | pointer.x = pointer.position![0] = convert(e.offsetX, 'px') 366 | pointer.y = pointer.position![1] = convert(e.offsetY, 'px') 367 | }) 368 | 369 | event('pointerleave', (e) => { 370 | if (!e.isPrimary) return 371 | pointer.x = null 372 | pointer.y = null 373 | pointer.position = null 374 | }) 375 | 376 | event('pointerdown', (e) => { 377 | if (!e.isPrimary) return 378 | pointer.button = e.button 379 | pointer.buttons[e.button] = true 380 | }) 381 | 382 | event('pointerup', (e) => { 383 | if (!e.isPrimary) return 384 | pointer.button = null 385 | delete pointer.buttons[e.button] 386 | }) 387 | 388 | POINTER_EVENTS.set(sketch, true) 389 | } 390 | 391 | return pointer 392 | } 393 | 394 | /** 395 | * Return a random number from `0` (inclusive) to `1` (exclusive) using the 396 | * sketch's seeded pseudo-random number generator. 397 | * 398 | * This is the same as `Math.random` but with deterministic randomness. 399 | */ 400 | export function random(): number 401 | export function random(min: number, max: number, step?: number): number 402 | export function random(min?: number, max?: number, step?: number): number { 403 | if (min == null) (min = 0), (max = 1) 404 | if (max == null) (max = min), (min = 0) 405 | const sketch = Sketch.assert() 406 | const prng = Sketch.prng(sketch) 407 | const r = prng() / 2 ** 32 408 | let value = r * (max - min + (step ?? 0)) 409 | if (step != null) { 410 | const s = 1 / step // avoid common floating point errors by dividing first 411 | value = Math.floor(value * s) / s 412 | } 413 | value += min 414 | return value 415 | } 416 | 417 | /** 418 | * Setup the layout and scene for a sketch. 419 | * 420 | * This method returns an object of all the resolved settings of a sketch, 421 | * including the resolved `width` and `height` of the canvas. 422 | */ 423 | export function settings(): Sketch['settings'] 424 | export function settings(config: Config): Sketch['settings'] 425 | export function settings(dimensions: Config['dimensions']): Sketch['settings'] 426 | export function settings( 427 | config?: Config | Config['dimensions'] 428 | ): Sketch['settings'] { 429 | if (typeof config === 'string' || Array.isArray(config)) { 430 | config = { dimensions: config } 431 | } 432 | 433 | const sketch = Sketch.assert() 434 | const settings = Sketch.settings(sketch, config ?? {}) 435 | return settings 436 | } 437 | 438 | // The shorthand for define a set of choices. 439 | type Choices = V extends Nameable 440 | ? 441 | | readonly V[] 442 | | readonly [number, V][] 443 | | Record 444 | | Record 445 | : 446 | | Exclude 447 | | readonly [number, V][] 448 | | Record> 449 | | Record 450 | 451 | // Normalize the shorthand for defining choices into schema objects. 452 | function normalizeChoices(shorthand: Choices): { 453 | names: string[] 454 | weights: number[] 455 | mapping: Record 456 | } { 457 | const names: string[] = [] 458 | const weights: number[] = [] 459 | const mapping: Record = {} 460 | const entries = Array.isArray(shorthand) 461 | ? shorthand.entries() 462 | : Object.entries(shorthand) 463 | 464 | for (const [i, v] of entries) { 465 | const [weight, value] = Array.isArray(v) ? v : [1, v] 466 | const name = typeof i === 'number' ? String(value) : i 467 | names.push(name) 468 | weights.push(weight) 469 | mapping[name] = value 470 | } 471 | 472 | return { names, weights, mapping } 473 | } 474 | --------------------------------------------------------------------------------