Updates code editor

This commit is contained in:
Steve Ruiz 2021-05-17 11:01:11 +01:00
parent e21748f7b7
commit abd310aa2e
24 changed files with 792 additions and 227 deletions

View file

@ -7,7 +7,9 @@ import state, { useSelector } from "state"
import { CodeFile } from "types"
import CodeDocs from "./code-docs"
import CodeEditor from "./code-editor"
import { getShapesFromCode } from "lib/code/generate"
import { generateFromCode } from "lib/code/generate"
import * as Panel from "../panel"
import { IconButton } from "../shared"
import {
X,
Code,
@ -51,8 +53,8 @@ export default function CodePanel() {
states: {
editingCode: {
on: {
RAN_CODE: "runCode",
SAVED_CODE: ["runCode", "saveCode"],
RAN_CODE: ["saveCode", "runCode"],
SAVED_CODE: ["saveCode", "runCode"],
CHANGED_CODE: { secretlyDo: "setCode" },
CLEARED_ERROR: { if: "hasError", do: "clearError" },
TOGGLED_DOCS: { to: "viewingDocs" },
@ -80,8 +82,8 @@ export default function CodePanel() {
let error = null
try {
const shapes = getShapesFromCode(data.code)
state.send("GENERATED_SHAPES_FROM_CODE", { shapes })
const { shapes, controls } = generateFromCode(data.code)
state.send("GENERATED_FROM_CODE", { shapes, controls })
} catch (e) {
console.error(e)
error = { message: e.message, ...getErrorLineAndColumn(e) }
@ -113,15 +115,10 @@ export default function CodePanel() {
const { error } = local.data
return (
<PanelContainer
data-bp-desktop
ref={rContainer}
dragMomentum={false}
isCollapsed={!isOpen}
>
<Panel.Root data-bp-desktop ref={rContainer} isCollapsed={!isOpen}>
{isOpen ? (
<Content>
<Header>
<Panel.Layout>
<Panel.Header>
<IconButton onClick={() => state.send("CLOSED_CODE_PANEL")}>
<X />
</IconButton>
@ -151,8 +148,8 @@ export default function CodePanel() {
<PlayCircle />
</IconButton>
</ButtonsGroup>
</Header>
<EditorContainer>
</Panel.Header>
<Panel.Content>
<CodeEditor
fontSize={fontSize}
readOnly={isReadOnly}
@ -163,149 +160,42 @@ export default function CodePanel() {
onKey={() => local.send("CLEARED_ERROR")}
/>
<CodeDocs isHidden={!local.isIn("viewingDocs")} />
</EditorContainer>
<ErrorContainer>
</Panel.Content>
<Panel.Footer>
{error &&
(error.line
? `(${Number(error.line) - 2}:${error.column}) ${error.message}`
: error.message)}
</ErrorContainer>
</Content>
</Panel.Footer>
</Panel.Layout>
) : (
<IconButton onClick={() => state.send("OPENED_CODE_PANEL")}>
<Code />
</IconButton>
)}
</PanelContainer>
</Panel.Root>
)
}
const PanelContainer = styled(motion.div, {
position: "absolute",
top: "48px",
right: "8px",
bottom: "48px",
backgroundColor: "$panel",
borderRadius: "4px",
overflow: "hidden",
border: "1px solid $border",
pointerEvents: "all",
userSelect: "none",
zIndex: 200,
button: {
border: "none",
},
variants: {
isCollapsed: {
true: {},
false: {},
},
},
})
const IconButton = styled("button", {
height: "40px",
width: "40px",
backgroundColor: "$panel",
borderRadius: "4px",
border: "1px solid $border",
padding: "0",
margin: "0",
display: "flex",
alignItems: "center",
justifyContent: "center",
outline: "none",
pointerEvents: "all",
cursor: "pointer",
"&:hover:not(:disabled)": {
backgroundColor: "$panel",
},
"&:disabled": {
opacity: "0.5",
},
svg: {
height: "20px",
width: "20px",
strokeWidth: "2px",
stroke: "$text",
},
})
const Content = styled("div", {
display: "grid",
gridTemplateColumns: "1fr",
gridTemplateRows: "auto 1fr 28px",
height: "100%",
width: 560,
minWidth: "100%",
maxWidth: 560,
overflow: "hidden",
userSelect: "none",
pointerEvents: "all",
})
const Header = styled("div", {
pointerEvents: "all",
display: "grid",
gridTemplateColumns: "auto 1fr",
alignItems: "center",
justifyContent: "center",
borderBottom: "1px solid $border",
"& button": {
gridColumn: "1",
gridRow: "1",
},
"& h3": {
gridColumn: "1 / span 3",
gridRow: "1",
textAlign: "center",
margin: "0",
padding: "0",
fontSize: "16px",
},
})
const ButtonsGroup = styled("div", {
gridRow: "1",
gridColumn: "3",
display: "flex",
})
const EditorContainer = styled("div", {
position: "relative",
pointerEvents: "all",
overflowY: "scroll",
})
const ErrorContainer = styled("div", {
overflowX: "scroll",
color: "$text",
font: "$debug",
padding: "0 12px",
display: "flex",
alignItems: "center",
})
const FontSizeButtons = styled("div", {
paddingRight: 4,
display: "flex",
flexDirection: "column",
"& > button": {
height: "50%",
width: "100%",
"&:nth-of-type(1)": {
paddingTop: 4,
alignItems: "flex-end",
},
"&:nth-of-type(2)": {
paddingBottom: 4,
alignItems: "flex-start",
},
"& svg": {

View file

@ -0,0 +1,145 @@
import state, { useSelector } from "state"
import styled from "styles"
import {
ControlType,
NumberCodeControl,
SelectCodeControl,
TextCodeControl,
VectorCodeControl,
} from "types"
export default function Control({ id }: { id: string }) {
const control = useSelector((s) => s.data.codeControls[id])
if (!control) return null
return (
<>
<label>{control.label}</label>
{control.type === ControlType.Number ? (
<NumberControl {...control} />
) : control.type === ControlType.Vector ? (
<VectorControl {...control} />
) : control.type === ControlType.Text ? (
<TextControl {...control} />
) : control.type === ControlType.Select ? (
<SelectControl {...control} />
) : null}
</>
)
}
function NumberControl({ id, min, max, step, value }: NumberCodeControl) {
return (
<Inputs>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) =>
state.send("CHANGED_CODE_CONTROL", {
[id]: Number(e.currentTarget.value),
})
}
/>
<input
type="number"
min={min}
max={max}
step={step}
value={value}
onChange={(e) =>
state.send("CHANGED_CODE_CONTROL", {
[id]: Number(e.currentTarget.value),
})
}
/>
</Inputs>
)
}
function VectorControl({ id, value, isNormalized }: VectorCodeControl) {
return (
<Inputs>
<input
type="range"
min={isNormalized ? -1 : -Infinity}
max={isNormalized ? 1 : Infinity}
step={0.01}
value={value[0]}
onChange={(e) =>
state.send("CHANGED_CODE_CONTROL", {
[id]: [Number(e.currentTarget.value), value[1]],
})
}
/>
<input
type="number"
min={isNormalized ? -1 : -Infinity}
max={isNormalized ? 1 : Infinity}
step={0.01}
value={value[0]}
onChange={(e) =>
state.send("CHANGED_CODE_CONTROL", {
[id]: [Number(e.currentTarget.value), value[1]],
})
}
/>
<input
type="range"
min={isNormalized ? -1 : -Infinity}
max={isNormalized ? 1 : Infinity}
step={0.01}
value={value[1]}
onChange={(e) =>
state.send("CHANGED_CODE_CONTROL", {
[id]: [value[0], Number(e.currentTarget.value)],
})
}
/>
<input
type="number"
min={isNormalized ? -1 : -Infinity}
max={isNormalized ? 1 : Infinity}
step={0.01}
value={value[1]}
onChange={(e) =>
state.send("CHANGED_CODE_CONTROL", {
[id]: [value[0], Number(e.currentTarget.value)],
})
}
/>
</Inputs>
)
}
function TextControl({}: TextCodeControl) {
return <></>
}
function SelectControl({}: SelectCodeControl) {
return <></>
}
const Inputs = styled("div", {
display: "flex",
gap: "8px",
height: "100%",
"& input": {
font: "$ui",
width: "64px",
fontSize: "$1",
border: "1px solid $inputBorder",
backgroundColor: "$input",
color: "$text",
height: "100%",
padding: "0px 6px",
},
"& input[type='range']": {
padding: 0,
flexGrow: 2,
},
})

View file

@ -0,0 +1,62 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import styled from "styles"
import React, { useEffect, useRef } from "react"
import state, { useSelector } from "state"
import { X, Code, PlayCircle } from "react-feather"
import { IconButton } from "components/shared"
import * as Panel from "../panel"
import Control from "./control"
import { deepCompareArrays } from "utils/utils"
export default function ControlPanel() {
const rContainer = useRef<HTMLDivElement>(null)
const codeControls = useSelector(
(state) => Object.keys(state.data.codeControls),
deepCompareArrays
)
const isOpen = true
return (
<Panel.Root data-bp-desktop ref={rContainer} isCollapsed={!isOpen}>
{isOpen ? (
<Panel.Layout>
<Panel.Header>
<IconButton onClick={() => state.send("CLOSED_CODE_PANEL")}>
<X />
</IconButton>
<h3>Controls</h3>
</Panel.Header>
<ControlsList>
{codeControls.map((id) => (
<Control key={id} id={id} />
))}
</ControlsList>
</Panel.Layout>
) : (
<IconButton onClick={() => state.send("OPENED_CODE_PANEL")}>
<Code />
</IconButton>
)}
</Panel.Root>
)
}
const ControlsList = styled(Panel.Content, {
padding: 12,
display: "grid",
gridTemplateColumns: "1fr 4fr",
gridAutoRows: "24px",
alignItems: "center",
gridColumnGap: "8px",
gridRowGap: "8px",
"& input": {
font: "$ui",
fontSize: "$1",
border: "1px solid $inputBorder",
backgroundColor: "$input",
color: "$text",
height: "100%",
padding: "0px 6px",
},
})

View file

@ -4,17 +4,46 @@ import Canvas from "./canvas/canvas"
import StatusBar from "./status-bar"
import Toolbar from "./toolbar"
import CodePanel from "./code-panel/code-panel"
import ControlsPanel from "./controls-panel/controls-panel"
import styled from "styles"
export default function Editor() {
useKeyboardEvents()
useLoadOnMount()
return (
<>
<Layout>
<Canvas />
<StatusBar />
<Toolbar />
<CodePanel />
</>
<LeftPanels>
<CodePanel />
<ControlsPanel />
</LeftPanels>
</Layout>
)
}
const Layout = styled("div", {
position: "fixed",
top: 0,
left: 0,
bottom: 0,
right: 0,
display: "grid",
gridTemplateRows: "40px 1fr 40px",
gridTemplateColumns: "auto 1fr",
gridTemplateAreas: `
"toolbar toolbar"
"leftPanels main"
"statusbar statusbar"
`,
})
const LeftPanels = styled("main", {
display: "grid",
gridArea: "leftPanels",
gridTemplateRows: "1fr auto",
padding: 8,
gap: 8,
})

79
components/panel.tsx Normal file
View file

@ -0,0 +1,79 @@
import styled from "styles"
export const Root = styled("div", {
position: "relative",
backgroundColor: "$panel",
borderRadius: "4px",
overflow: "hidden",
border: "1px solid $border",
pointerEvents: "all",
userSelect: "none",
zIndex: 200,
boxShadow: "0px 2px 25px rgba(0,0,0,.16)",
variants: {
isCollapsed: {
true: {},
false: {},
},
},
})
export const Layout = styled("div", {
display: "grid",
gridTemplateColumns: "1fr",
gridTemplateRows: "auto 1fr",
gridAutoRows: "28px",
height: "100%",
width: 560,
minWidth: "100%",
maxWidth: 560,
overflow: "hidden",
userSelect: "none",
pointerEvents: "all",
})
export const Header = styled("div", {
pointerEvents: "all",
display: "grid",
gridTemplateColumns: "auto 1fr auto",
alignItems: "center",
justifyContent: "center",
borderBottom: "1px solid $border",
"& button": {
gridColumn: "1",
gridRow: "1",
},
"& h3": {
gridColumn: "1 / span 3",
gridRow: "1",
textAlign: "center",
margin: "0",
padding: "0",
fontSize: "13px",
},
})
export const ButtonsGroup = styled("div", {
gridRow: "1",
gridColumn: "3",
display: "flex",
})
export const Content = styled("div", {
position: "relative",
pointerEvents: "all",
overflowY: "scroll",
})
export const Footer = styled("div", {
overflowX: "scroll",
color: "$text",
font: "$debug",
padding: "0 12px",
display: "flex",
alignItems: "center",
borderTop: "1px solid $border",
})

32
components/shared.tsx Normal file
View file

@ -0,0 +1,32 @@
import styled from "styles"
export const IconButton = styled("button", {
height: "32px",
width: "32px",
backgroundColor: "$panel",
borderRadius: "4px",
padding: "0",
margin: "0",
display: "flex",
alignItems: "center",
justifyContent: "center",
outline: "none",
border: "none",
pointerEvents: "all",
cursor: "pointer",
"&:hover:not(:disabled)": {
backgroundColor: "$panel",
},
"&:disabled": {
opacity: "0.5",
},
svg: {
height: "16px",
width: "16px",
strokeWidth: "2px",
stroke: "$text",
},
})

View file

@ -70,20 +70,16 @@ export default function Toolbar() {
>
Rectangle
</Button>
<Button onClick={() => state.send("RESET_CAMERA")}>Reset Camera</Button>
</Section>
</ToolbarContainer>
)
}
const ToolbarContainer = styled("div", {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: 40,
gridArea: "toolbar",
userSelect: "none",
borderBottom: "1px solid black",
gridArea: "status",
display: "grid",
gridTemplateColumns: "auto 1fr auto",
alignItems: "center",

View file

@ -1,9 +1,12 @@
import CodeShape from "./index"
import { v4 as uuid } from "uuid"
import { CircleShape, ShapeType } from "types"
import { vectorToPoint } from "utils/utils"
export default class Circle extends CodeShape<CircleShape> {
constructor(props = {} as Partial<CircleShape>) {
props.point = vectorToPoint(props.point)
super({
id: uuid(),
type: ShapeType.Circle,
@ -23,6 +26,14 @@ export default class Circle extends CodeShape<CircleShape> {
})
}
export() {
const shape = { ...this.shape }
shape.point = vectorToPoint(shape.point)
return shape
}
get radius() {
return this.shape.radius
}

57
lib/code/control.ts Normal file
View file

@ -0,0 +1,57 @@
import {
CodeControl,
ControlType,
NumberCodeControl,
VectorCodeControl,
} from "types"
import { v4 as uuid } from "uuid"
import Vector from "./vector"
export const controls: Record<string, any> = {}
export const codeControls = new Set<CodeControl>([])
export class Control<T extends CodeControl> {
control: T
constructor(control: Omit<T, "id">) {
this.control = { ...control, id: uuid() } as T
codeControls.add(this.control)
// Could there be a better way to prevent this?
// When updating, constructor should just bind to
// the existing control rather than creating a new one?
if (!(window as any).isUpdatingCode) {
controls[this.control.label] = this.control.value
}
}
destroy() {
codeControls.delete(this.control)
delete controls[this.control.label]
}
}
export class NumberControl extends Control<NumberCodeControl> {
constructor(options: Omit<NumberCodeControl, "id" | "type">) {
const { value = 0, step = 1 } = options
super({
type: ControlType.Number,
...options,
value,
step,
})
}
}
export class VectorControl extends Control<VectorCodeControl> {
constructor(options: Omit<VectorCodeControl, "id" | "type">) {
const { value = [0, 0], isNormalized = false } = options
super({
type: ControlType.Vector,
...options,
value,
isNormalized,
})
}
}

View file

@ -1,9 +1,12 @@
import CodeShape from "./index"
import { v4 as uuid } from "uuid"
import { DotShape, ShapeType } from "types"
import { vectorToPoint } from "utils/utils"
export default class Dot extends CodeShape<DotShape> {
constructor(props = {} as Partial<DotShape>) {
props.point = vectorToPoint(props.point)
super({
id: uuid(),
type: ShapeType.Dot,
@ -21,4 +24,12 @@ export default class Dot extends CodeShape<DotShape> {
...props,
})
}
export() {
const shape = { ...this.shape }
shape.point = vectorToPoint(shape.point)
return shape
}
}

View file

@ -1,9 +1,12 @@
import CodeShape from "./index"
import { v4 as uuid } from "uuid"
import { EllipseShape, ShapeType } from "types"
import { vectorToPoint } from "utils/utils"
export default class Ellipse extends CodeShape<EllipseShape> {
constructor(props = {} as Partial<EllipseShape>) {
props.point = vectorToPoint(props.point)
super({
id: uuid(),
type: ShapeType.Ellipse,
@ -24,7 +27,19 @@ export default class Ellipse extends CodeShape<EllipseShape> {
})
}
get radius() {
return this.shape.radius
export() {
const shape = { ...this.shape }
shape.point = vectorToPoint(shape.point)
return shape
}
get radiusX() {
return this.shape.radiusX
}
get radiusY() {
return this.shape.radiusY
}
}

View file

@ -7,9 +7,11 @@ import Ray from "./ray"
import Line from "./line"
import Vector from "./vector"
import Utils from "./utils"
import { NumberControl, VectorControl, codeControls, controls } from "./control"
import { codeShapes } from "./index"
import { CodeControl } from "types"
const scope = {
const baseScope = {
Dot,
Circle,
Ellipse,
@ -19,6 +21,8 @@ const scope = {
Rectangle,
Vector,
Utils,
VectorControl,
NumberControl,
}
/**
@ -26,8 +30,12 @@ const scope = {
* collected shapes as an array.
* @param code
*/
export function getShapesFromCode(code: string) {
export function generateFromCode(code: string) {
codeControls.clear()
codeShapes.clear()
;(window as any).isUpdatingCode = false
const scope = { ...baseScope, controls }
new Function(...Object.keys(scope), `${code}`)(...Object.values(scope))
@ -36,5 +44,39 @@ export function getShapesFromCode(code: string) {
return instance.shape
})
return generatedShapes
const generatedControls = Array.from(codeControls.values())
return { shapes: generatedShapes, controls: generatedControls }
}
/**
* Evaluate code, collecting generated shapes in the shape set. Return the
* collected shapes as an array.
* @param code
*/
export function updateFromCode(
code: string,
controls: Record<string, CodeControl>
) {
codeShapes.clear()
;(window as any).isUpdatingCode = true
const scope = {
...baseScope,
controls: Object.fromEntries(
Object.entries(controls).map(([id, control]) => [
control.label,
control.value,
])
),
}
new Function(...Object.keys(scope), `${code}`)(...Object.values(scope))
const generatedShapes = Array.from(codeShapes.values()).map((instance) => {
instance.shape.isGenerated = true
return instance.shape
})
return { shapes: generatedShapes }
}

View file

@ -1,9 +1,15 @@
import { Shape } from "types"
import * as vec from "utils/vec"
import { getShapeUtils } from "lib/shapes"
import * as vec from "utils/vec"
import Vector from "./vector"
import { vectorToPoint } from "utils/utils"
export const codeShapes = new Set<CodeShape<Shape>>([])
type WithVectors<T extends Shape> = {
[key in keyof T]: number[] extends T[key] ? Vector : T[key]
}
/**
* A base class for code shapes. Note that creating a shape adds it to the
* shape map, while deleting it removes it from the collected shapes set
@ -20,12 +26,12 @@ export default class CodeShape<T extends Shape> {
codeShapes.delete(this)
}
moveTo(point: number[]) {
this.shape.point = point
moveTo(point: Vector) {
this.shape.point = vectorToPoint(point)
}
translate(delta: number[]) {
this.shape.point = vec.add(this._shape.point, delta)
translate(delta: Vector) {
this.shape.point = vec.add(this._shape.point, vectorToPoint(delta))
}
rotate(rotation: number) {
@ -40,8 +46,8 @@ export default class CodeShape<T extends Shape> {
return getShapeUtils(this.shape).getBounds(this.shape)
}
hitTest(point: number[]) {
return getShapeUtils(this.shape).hitTest(this.shape, point)
hitTest(point: Vector) {
return getShapeUtils(this.shape).hitTest(this.shape, vectorToPoint(point))
}
get shape() {

View file

@ -1,9 +1,13 @@
import CodeShape from "./index"
import { v4 as uuid } from "uuid"
import { LineShape, ShapeType } from "types"
import { vectorToPoint } from "utils/utils"
export default class Line extends CodeShape<LineShape> {
constructor(props = {} as Partial<LineShape>) {
props.point = vectorToPoint(props.point)
props.direction = vectorToPoint(props.direction)
super({
id: uuid(),
type: ShapeType.Line,
@ -22,4 +26,17 @@ export default class Line extends CodeShape<LineShape> {
...props,
})
}
export() {
const shape = { ...this.shape }
shape.point = vectorToPoint(shape.point)
shape.direction = vectorToPoint(shape.direction)
return shape
}
get direction() {
return this.shape.direction
}
}

View file

@ -1,9 +1,13 @@
import CodeShape from "./index"
import { v4 as uuid } from "uuid"
import { PolylineShape, ShapeType } from "types"
import { vectorToPoint } from "utils/utils"
export default class Polyline extends CodeShape<PolylineShape> {
constructor(props = {} as Partial<PolylineShape>) {
props.point = vectorToPoint(props.point)
props.points = props.points.map(vectorToPoint)
super({
id: uuid(),
type: ShapeType.Polyline,
@ -22,4 +26,17 @@ export default class Polyline extends CodeShape<PolylineShape> {
...props,
})
}
export() {
const shape = { ...this.shape }
shape.point = vectorToPoint(shape.point)
shape.points = shape.points.map(vectorToPoint)
return shape
}
get points() {
return this.shape.points
}
}

View file

@ -1,9 +1,13 @@
import CodeShape from "./index"
import { v4 as uuid } from "uuid"
import { RayShape, ShapeType } from "types"
import { vectorToPoint } from "utils/utils"
export default class Ray extends CodeShape<RayShape> {
constructor(props = {} as Partial<RayShape>) {
props.point = vectorToPoint(props.point)
props.direction = vectorToPoint(props.direction)
super({
id: uuid(),
type: ShapeType.Ray,
@ -22,4 +26,17 @@ export default class Ray extends CodeShape<RayShape> {
...props,
})
}
export() {
const shape = { ...this.shape }
shape.point = vectorToPoint(shape.point)
shape.direction = vectorToPoint(shape.direction)
return shape
}
get direction() {
return this.shape.direction
}
}

View file

@ -1,9 +1,13 @@
import CodeShape from "./index"
import { v4 as uuid } from "uuid"
import { RectangleShape, ShapeType } from "types"
import { vectorToPoint } from "utils/utils"
export default class Rectangle extends CodeShape<RectangleShape> {
constructor(props = {} as Partial<RectangleShape>) {
props.point = vectorToPoint(props.point)
props.size = vectorToPoint(props.size)
super({
id: uuid(),
type: ShapeType.Rectangle,

View file

@ -1,6 +1,6 @@
import Command from "./command"
import history from "../history"
import { Data, Shape } from "types"
import { CodeControl, Data, Shape } from "types"
import { current } from "immer"
export default function setGeneratedShapes(
@ -8,12 +8,24 @@ export default function setGeneratedShapes(
currentPageId: string,
generatedShapes: Shape[]
) {
const cData = current(data)
const prevGeneratedShapes = Object.values(
current(data).document.pages[currentPageId].shapes
cData.document.pages[currentPageId].shapes
).filter((shape) => shape.isGenerated)
const currentShapes = data.document.pages[currentPageId].shapes
// Remove previous generated shapes
for (let id in currentShapes) {
if (currentShapes[id].isGenerated) {
delete currentShapes[id]
}
}
// Add new ones
for (let shape of generatedShapes) {
data.document.pages[currentPageId].shapes[shape.id] = shape
currentShapes[shape.id] = shape
}
history.execute(

View file

@ -1,13 +1,13 @@
import translate from "./translate"
import transform from "./transform"
import generateShapes from "./generate-shapes"
import generate from "./generate"
import createShape from "./create-shape"
import direction from "./direction"
const commands = {
translate,
transform,
generateShapes,
generate,
createShape,
direction,
}

View file

@ -121,32 +121,32 @@ export const defaultDocument: Data["document"] = {
name: "index.ts",
code: `
new Dot({
point: [0, 0],
point: new Vector(0, 0),
})
new Circle({
point: [200, 0],
point: new Vector(200, 0),
radius: 50,
})
new Ellipse({
point: [400, 0],
point: new Vector(400, 0),
radiusX: 50,
radiusY: 75
})
new Rectangle({
point: [0, 300],
point: new Vector(0, 300),
})
new Line({
point: [200, 300],
direction: [1,0.2]
point: new Vector(200, 300),
direction: new Vector(1,0.2)
})
new Polyline({
point: [400, 300],
points: [[0, 200], [0,0], [200, 200], [200, 0]],
point: new Vector(400, 300),
points: [new Vector(0, 200), new Vector(0,0), new Vector(200, 200), new Vector(200, 0)],
})
`,
},

View file

@ -9,12 +9,15 @@ import {
Shapes,
TransformCorner,
TransformEdge,
CodeControl,
} from "types"
import { defaultDocument } from "./data"
import shapeUtilityMap, { getShapeUtils } from "lib/shapes"
import history from "state/history"
import * as Sessions from "./sessions"
import commands from "./commands"
import { controls } from "lib/code/control"
import { generateFromCode, updateFromCode } from "lib/code/generate"
const initialData: Data = {
isReadOnly: false,
@ -32,6 +35,7 @@ const initialData: Data = {
selectedIds: new Set([]),
currentPageId: "page0",
currentCodeFileId: "file0",
codeControls: {},
document: defaultDocument,
}
@ -52,6 +56,7 @@ const state = createState({
SELECTED_LINE_TOOL: { unless: "isReadOnly", to: "line" },
SELECTED_POLYLINE_TOOL: { unless: "isReadOnly", to: "polyline" },
SELECTED_RECTANGLE_TOOL: { unless: "isReadOnly", to: "rectangle" },
RESET_CAMERA: "resetCamera",
},
initial: "loading",
states: {
@ -70,9 +75,10 @@ const state = createState({
CANCELLED: { do: "clearSelectedIds" },
DELETED: { do: "deleteSelectedIds" },
SAVED_CODE: "saveCode",
GENERATED_SHAPES_FROM_CODE: "setGeneratedShapes",
GENERATED_FROM_CODE: ["setCodeControls", "setGeneratedShapes"],
INCREASED_CODE_FONT_SIZE: "increaseCodeFontSize",
DECREASED_CODE_FONT_SIZE: "decreaseCodeFontSize",
CHANGED_CODE_CONTROL: "updateControls",
},
initial: "notPointing",
states: {
@ -437,6 +443,12 @@ const state = createState({
data.selectedIds.add(data.pointedId)
},
// Camera
resetCamera(data) {
data.camera.zoom = 1
data.camera.point = [window.innerWidth / 2, window.innerHeight / 2]
document.documentElement.style.setProperty("--camera-zoom", "1")
},
zoomCamera(data, payload: { delta: number; point: number[] }) {
const { camera } = data
const p0 = screenToWorld(payload.point, data)
@ -493,8 +505,16 @@ const state = createState({
},
// Code
setGeneratedShapes(data, payload: { shapes: Shape[] }) {
commands.generateShapes(data, data.currentPageId, payload.shapes)
setGeneratedShapes(
data,
payload: { shapes: Shape[]; controls: CodeControl[] }
) {
commands.generate(data, data.currentPageId, payload.shapes)
},
setCodeControls(data, payload: { controls: CodeControl[] }) {
data.codeControls = Object.fromEntries(
payload.controls.map((control) => [control.id, control])
)
},
increaseCodeFontSize(data) {
data.settings.fontSize++
@ -502,6 +522,28 @@ const state = createState({
decreaseCodeFontSize(data) {
data.settings.fontSize--
},
updateControls(data, payload: { [key: string]: any }) {
for (let key in payload) {
data.codeControls[key].value = payload[key]
}
history.disable()
data.selectedIds.clear()
try {
const { shapes } = updateFromCode(
data.document.code[data.currentCodeFileId].code,
data.codeControls
)
commands.generate(data, data.currentPageId, shapes)
} catch (e) {
console.error(e)
}
history.enable()
},
// Data
saveCode(data, payload: { code: string }) {
@ -524,9 +566,9 @@ const state = createState({
document: { pages },
} = data
const shapes = Array.from(selectedIds.values()).map(
(id) => pages[currentPageId].shapes[id]
)
const shapes = Array.from(selectedIds.values())
.map((id) => pages[currentPageId].shapes[id])
.filter(Boolean)
if (selectedIds.size === 0) return null

View file

@ -15,6 +15,8 @@ const { styled, global, css, theme, getCssString } = createCss({
border: "#aaa",
panel: "#fefefe",
text: "#000",
input: "#f3f3f3",
inputBorder: "#ddd",
},
space: {},
fontSizes: {
@ -47,7 +49,7 @@ const { styled, global, css, theme, getCssString } = createCss({
min !== undefined && max !== undefined
? `clamp(${min}, ${val} / var(--camera-zoom), ${max})`
: min !== undefined
? `max(${min}, ${val} / var(--camera-zoom))`
? `min(${min}, ${val} / var(--camera-zoom))`
: `calc(${val} / var(--camera-zoom))`,
}
}

173
types.ts
View file

@ -2,6 +2,10 @@ import * as monaco from "monaco-editor/esm/vs/editor/editor.api"
import React from "react"
/* -------------------------------------------------- */
/* Client State */
/* -------------------------------------------------- */
export interface Data {
isReadOnly: boolean
settings: {
@ -18,17 +22,16 @@ export interface Data {
hoveredId?: string
currentPageId: string
currentCodeFileId: string
codeControls: Record<string, CodeControl>
document: {
pages: Record<string, Page>
code: Record<string, CodeFile>
}
}
export interface CodeFile {
id: string
name: string
code: string
}
/* -------------------------------------------------- */
/* Document */
/* -------------------------------------------------- */
export interface Page {
id: string
@ -46,12 +49,14 @@ export enum ShapeType {
Ray = "ray",
Polyline = "polyline",
Rectangle = "rectangle",
// Glob = "glob",
// Spline = "spline",
// Cubic = "cubic",
// Conic = "conic",
}
// Consider:
// Glob = "glob",
// Spline = "spline",
// Cubic = "cubic",
// Conic = "conic",
export interface BaseShape {
id: string
type: ShapeType
@ -108,31 +113,6 @@ export type Shape =
| PolylineShape
| RectangleShape
export interface Bounds {
minX: number
minY: number
maxX: number
maxY: number
width: number
height: number
}
export interface ShapeBounds extends Bounds {
id: string
}
export interface PointSnapshot extends Bounds {
nx: number
nmx: number
ny: number
nmy: number
}
export interface BoundsSnapshot extends PointSnapshot {
nw: number
nh: number
}
export interface Shapes extends Record<ShapeType, Shape> {
[ShapeType.Dot]: DotShape
[ShapeType.Circle]: CircleShape
@ -143,27 +123,16 @@ export interface Shapes extends Record<ShapeType, Shape> {
[ShapeType.Rectangle]: RectangleShape
}
export type Difference<A, B> = A extends B ? never : A
export type ShapeSpecificProps<T extends Shape> = Pick<
T,
Difference<keyof T, keyof BaseShape>
>
export type ShapeIndicatorProps<T extends Shape> = ShapeSpecificProps<T>
export type ShapeUtil<K extends Shape> = {
create(props: Partial<K>): K
getBounds(shape: K): Bounds
hitTest(shape: K, test: number[]): boolean
hitTestBounds(shape: K, bounds: Bounds): boolean
rotate(shape: K): K
translate(shape: K, delta: number[]): K
scale(shape: K, scale: number): K
stretch(shape: K, scaleX: number, scaleY: number): K
render(shape: K): JSX.Element
export interface CodeFile {
id: string
name: string
code: string
}
/* -------------------------------------------------- */
/* Editor UI */
/* -------------------------------------------------- */
export interface PointerInfo {
target: string
pointerId: number
@ -189,6 +158,104 @@ export enum TransformCorner {
BottomLeft = "bottom_left_corner",
}
export interface Bounds {
minX: number
minY: number
maxX: number
maxY: number
width: number
height: number
}
export interface ShapeBounds extends Bounds {
id: string
}
export interface PointSnapshot extends Bounds {
nx: number
nmx: number
ny: number
nmy: number
}
export interface BoundsSnapshot extends PointSnapshot {
nw: number
nh: number
}
export type Difference<A, B> = A extends B ? never : A
export type ShapeSpecificProps<T extends Shape> = Pick<
T,
Difference<keyof T, keyof BaseShape>
>
export type ShapeIndicatorProps<T extends Shape> = ShapeSpecificProps<T>
export type ShapeUtil<K extends Shape> = {
create(props: Partial<K>): K
getBounds(shape: K): Bounds
hitTest(shape: K, test: number[]): boolean
hitTestBounds(shape: K, bounds: Bounds): boolean
rotate(shape: K): K
translate(shape: K, delta: number[]): K
scale(shape: K, scale: number): K
stretch(shape: K, scaleX: number, scaleY: number): K
render(shape: K): JSX.Element
}
/* -------------------------------------------------- */
/* Code Editor */
/* -------------------------------------------------- */
export type IMonaco = typeof monaco
export type IMonacoEditor = monaco.editor.IStandaloneCodeEditor
export enum ControlType {
Number = "number",
Vector = "vector",
Text = "text",
Select = "select",
}
export interface BaseCodeControl {
id: string
type: ControlType
label: string
}
export interface NumberCodeControl extends BaseCodeControl {
type: ControlType.Number
min?: number
max?: number
value: number
step: number
format?: (value: number) => number
}
export interface VectorCodeControl extends BaseCodeControl {
type: ControlType.Vector
value: number[]
isNormalized: boolean
format?: (value: number[]) => number[]
}
export interface TextCodeControl extends BaseCodeControl {
type: ControlType.Text
value: string
format?: (value: string) => string
}
export interface SelectCodeControl<T extends string = "">
extends BaseCodeControl {
type: ControlType.Select
value: T
options: T[]
format?: (string: T) => string
}
export type CodeControl =
| NumberCodeControl
| VectorCodeControl
| TextCodeControl
| SelectCodeControl

View file

@ -1,3 +1,4 @@
import Vector from "lib/code/vector"
import React from "react"
import { Data, Bounds, TransformEdge, TransformCorner } from "types"
import * as svg from "./svg"
@ -801,7 +802,7 @@ export function throttle<P extends any[], T extends (...args: P) => any>(
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let inThrottle: boolean, lastFn: any, lastTime: number
return function(...args: P) {
return function (...args: P) {
if (preventDefault) args[0].preventDefault()
// eslint-disable-next-line @typescript-eslint/no-this-alias
const context = this
@ -811,7 +812,7 @@ export function throttle<P extends any[], T extends (...args: P) => any>(
inThrottle = true
} else {
clearTimeout(lastFn)
lastFn = setTimeout(function() {
lastFn = setTimeout(function () {
if (Date.now() - lastTime >= wait) {
fn.apply(context, args)
lastTime = Date.now()
@ -950,3 +951,14 @@ export function getTransformAnchor(
return anchor
}
export function vectorToPoint(point: number[] | Vector | undefined) {
if (typeof point === "undefined") {
return [0, 0]
}
if (point instanceof Vector) {
return [point.x, point.y]
}
return point
}