diff --git a/components/canvas/bounds-bg.tsx b/components/canvas/bounds-bg.tsx index 93fc56721..807583caf 100644 --- a/components/canvas/bounds-bg.tsx +++ b/components/canvas/bounds-bg.tsx @@ -16,8 +16,8 @@ export default function BoundsBg() { ref={rBounds} x={minX} y={minY} - width={width} - height={height} + width={Math.max(1, width)} + height={Math.max(1, height)} onPointerDown={(e) => { if (e.buttons !== 1) return e.stopPropagation() diff --git a/components/canvas/bounds.tsx b/components/canvas/bounds.tsx index 6032ef276..57469e4f0 100644 --- a/components/canvas/bounds.tsx +++ b/components/canvas/bounds.tsx @@ -16,8 +16,6 @@ export default function Bounds() { const p = 4 / zoom const cp = p * 2 - if (width < p || height < p) return null - return ( - {width * zoom > 8 && height * zoom > 8 && ( - <> - - - - - - - - - - )} + + + + + + + + ) } @@ -142,6 +136,7 @@ function Corner({ rCorner.current.setPointerCapture(e.pointerId) state.send("POINTED_BOUNDS_CORNER", inputs.pointerDown(e, corner)) }} + onPointerCancelCapture={() => console.log("oops")} onPointerUp={(e) => { e.stopPropagation() rCorner.current.releasePointerCapture(e.pointerId) diff --git a/components/canvas/shape.tsx b/components/canvas/shape.tsx index c1322d605..cae8aea65 100644 --- a/components/canvas/shape.tsx +++ b/components/canvas/shape.tsx @@ -7,6 +7,7 @@ import styled from "styles" function Shape({ id }: { id: string }) { const rGroup = useRef(null) + const isHovered = useSelector((state) => state.data.hoveredId === id) const isSelected = useSelector((state) => state.values.selectedIds.has(id)) const shape = useSelector( @@ -32,23 +33,35 @@ function Shape({ id }: { id: string }) { ) const handlePointerEnter = useCallback( - () => state.send("HOVERED_SHAPE", { id }), - [id] + (e: React.PointerEvent) => { + state.send("HOVERED_SHAPE", inputs.pointerEnter(e, id)) + }, + [id, shape] + ) + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + state.send("MOVED_OVER_SHAPE", inputs.pointerEnter(e, id)) + }, + [id, shape] ) const handlePointerLeave = useCallback( - () => state.send("UNHOVERED_SHAPE", { id }), + () => state.send("UNHOVERED_SHAPE", { target: id }), [id] ) + return ( {getShapeUtils(shape).render(shape)} @@ -86,13 +99,12 @@ const StyledGroup = styled("g", { [`& ${Indicator}`]: { stroke: "$selected", }, - [`&:hover ${HoverIndicator}`]: { - opacity: "1", - stroke: "$hint", - }, }, - false: { - [`&:hover ${HoverIndicator}`]: { + false: {}, + }, + isHovered: { + true: { + [`& ${HoverIndicator}`]: { opacity: "1", stroke: "$hint", }, diff --git a/components/code-panel/code-as-string.ts b/components/code-panel/code-as-string.ts new file mode 100644 index 000000000..bbd31b4e4 --- /dev/null +++ b/components/code-panel/code-as-string.ts @@ -0,0 +1,9 @@ +// This is the code library. + +export default ` + +// Hello world +const name = "steve" +const age = 93 + +` diff --git a/components/code-panel/code-docs.tsx b/components/code-panel/code-docs.tsx new file mode 100644 index 000000000..9fcb9eae1 --- /dev/null +++ b/components/code-panel/code-docs.tsx @@ -0,0 +1,101 @@ +import styled from "styles" + +export default function CodeDocs({ isHidden }: { isHidden: boolean }) { + return ( + +

Docs

+
+ ) +} + +const StyledDocs = styled("div", { + position: "absolute", + backgroundColor: "$panel", + top: 0, + left: 0, + width: "100%", + height: "100%", + padding: 16, + font: "$docs", + overflowY: "scroll", + userSelect: "none", + paddingBottom: 64, + + variants: { + isHidden: { + true: { + visibility: "hidden", + }, + false: { + visibility: "visible", + }, + }, + }, + + "& ol": {}, + + "& li": { + marginTop: 8, + marginBottom: 4, + }, + + "& code": { + font: "$mono", + }, + + "& hr": { + margin: "32px 0", + borderColor: "$muted", + }, + + "& h2": { + margin: "24px 0px", + }, + + "& h3": { + fontSize: 20, + margin: "48px 0px 32px 0px", + }, + + "& h3 > code": { + fontWeight: 600, + font: "$monoheading", + }, + + "& h4": { + margin: "32px 0px 0px 0px", + }, + + "& h4 > code": { + font: "$monoheading", + fontSize: 16, + userSelect: "all", + }, + + "& h4 > code > i": { + fontSize: 14, + color: "$muted", + }, + + "& pre": { + backgroundColor: "$bounds_bg", + padding: 16, + borderRadius: 4, + userSelect: "all", + margin: "24px 0", + }, + + "& p > code, blockquote > code": { + backgroundColor: "$bounds_bg", + padding: "2px 4px", + borderRadius: 2, + color: "$code", + }, + + "& blockquote": { + backgroundColor: "rgba(144, 144, 144, .05)", + padding: 12, + margin: "20px 0", + borderRadius: 8, + }, +}) diff --git a/components/code-panel/code-editor.tsx b/components/code-panel/code-editor.tsx new file mode 100644 index 000000000..48621d085 --- /dev/null +++ b/components/code-panel/code-editor.tsx @@ -0,0 +1,214 @@ +import Editor, { Monaco } from "@monaco-editor/react" +import useTheme from "hooks/useTheme" +import prettier from "prettier/standalone" +import parserTypeScript from "prettier/parser-typescript" +import codeAsString from "./code-as-string" +import React, { useCallback, useEffect, useRef } from "react" +import styled from "styles" +import { IMonaco, IMonacoEditor } from "types" + +interface Props { + value: string + error: { line: number } + fontSize: number + monacoRef?: React.MutableRefObject + editorRef?: React.MutableRefObject + readOnly?: boolean + onMount?: (value: string, editor: IMonacoEditor) => void + onUnmount?: (editor: IMonacoEditor) => void + onChange?: (value: string, editor: IMonacoEditor) => void + onSave?: (value: string, editor: IMonacoEditor) => void + onError?: (error: Error, line: number, col: number) => void + onKey?: () => void +} + +export default function CodeEditor({ + editorRef, + monacoRef, + fontSize, + value, + error, + readOnly, + onChange, + onSave, + onKey, +}: Props) { + const { theme } = useTheme() + const rEditor = useRef(null) + const rMonaco = useRef(null) + + const handleBeforeMount = useCallback((monaco: Monaco) => { + if (monacoRef) { + monacoRef.current = monaco + } + rMonaco.current = monaco + + monaco.languages.typescript.javascriptDefaults.setCompilerOptions({ + allowJs: true, + checkJs: false, + strict: false, + noLib: true, + lib: ["es6"], + target: monaco.languages.typescript.ScriptTarget.ES2015, + allowNonTsExtensions: true, + }) + + monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true) + + monaco.languages.typescript.javascriptDefaults.setEagerModelSync(true) + + monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({ + noSemanticValidation: true, + noSyntaxValidation: true, + }) + + monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ + noSemanticValidation: true, + noSyntaxValidation: true, + }) + + monaco.languages.typescript.javascriptDefaults.addExtraLib(codeAsString) + + monaco.languages.registerDocumentFormattingEditProvider("javascript", { + async provideDocumentFormattingEdits(model) { + const text = prettier.format(model.getValue(), { + parser: "typescript", + plugins: [parserTypeScript], + singleQuote: true, + trailingComma: "es5", + semi: false, + }) + + return [ + { + range: model.getFullModelRange(), + text, + }, + ] + }, + }) + }, []) + + const handleMount = useCallback((editor: IMonacoEditor) => { + if (editorRef) { + editorRef.current = editor + } + rEditor.current = editor + + editor.updateOptions({ + fontSize, + wordBasedSuggestions: false, + minimap: { enabled: false }, + lightbulb: { + enabled: false, + }, + readOnly, + }) + }, []) + + const handleChange = useCallback((code: string | undefined) => { + onChange(code, rEditor.current) + }, []) + + const handleKeydown = useCallback( + (e: React.KeyboardEvent) => { + onKey && onKey() + e.stopPropagation() + const metaKey = navigator.platform.match("Mac") ? e.metaKey : e.ctrlKey + if (e.key === "s" && metaKey) { + const editor = rEditor.current + if (!editor) return + editor + .getAction("editor.action.formatDocument") + .run() + .then(() => + onSave(rEditor.current?.getModel().getValue(), rEditor.current) + ) + + e.preventDefault() + } + if (e.key === "p" && metaKey) { + e.preventDefault() + } + if (e.key === "d" && metaKey) { + e.preventDefault() + } + }, + [] + ) + + const handleKeyUp = useCallback( + (e: React.KeyboardEvent) => e.stopPropagation(), + [] + ) + + const rDecorations = useRef([]) + + useEffect(() => { + const monaco = rMonaco.current + if (!monaco) return + const editor = rEditor.current + if (!editor) return + + if (!error) { + rDecorations.current = editor.deltaDecorations(rDecorations.current, []) + return + } + + if (!error.line) return + + rDecorations.current = editor.deltaDecorations(rDecorations.current, [ + { + range: new monaco.Range( + Number(error.line) - 1, + 0, + Number(error.line) - 1, + 0 + ), + options: { + isWholeLine: true, + className: "editorLineError", + }, + }, + ]) + }, [error]) + + useEffect(() => { + const monaco = rMonaco.current + if (!monaco) return + monaco.editor.setTheme(theme === "dark" ? "vs-dark" : "light") + }, [theme]) + + useEffect(() => { + const editor = rEditor.current + if (!editor) return + + editor.updateOptions({ + fontSize, + }) + }, [fontSize]) + + return ( + + + + ) +} + +const EditorContainer = styled("div", { + height: "100%", + pointerEvents: "all", + userSelect: "all", + + ".editorLineError": { + backgroundColor: "$lineError", + }, +}) diff --git a/components/code-panel/code-panel.tsx b/components/code-panel/code-panel.tsx new file mode 100644 index 000000000..0125eeaf4 --- /dev/null +++ b/components/code-panel/code-panel.tsx @@ -0,0 +1,315 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import React, { useEffect, useRef } from "react" +import state, { useSelector } from "state" +import { motion } from "framer-motion" +import { CodeFile } from "types" +import { useStateDesigner } from "@state-designer/react" +import CodeDocs from "./code-docs" +import CodeEditor from "./code-editor" +import { + X, + Code, + Info, + PlayCircle, + ChevronUp, + ChevronDown, +} from "react-feather" +import styled from "styles" + +// import evalCode from "lib/code" + +const getErrorLineAndColumn = (e: any) => { + if ("line" in e) { + return { line: Number(e.line), column: e.column } + } + + const result = e.stack.match(/:([0-9]+):([0-9]+)/) + if (result) { + return { line: Number(result[1]) - 1, column: result[2] } + } +} + +export default function CodePanel() { + const rContainer = useRef(null) + + const fileId = "file0" + const isReadOnly = useSelector((s) => s.data.isReadOnly) + const file = useSelector((s) => s.data.document.code[fileId]) + const isOpen = true + const fontSize = useSelector((s) => s.data.settings.fontSize) + + const local = useStateDesigner({ + data: { + code: file.code, + error: null as { message: string; line: number; column: number } | null, + }, + on: { + MOUNTED: "setCode", + CHANGED_FILE: "loadFile", + }, + initial: "editingCode", + states: { + editingCode: { + on: { + RAN_CODE: "runCode", + SAVED_CODE: ["runCode", "saveCode"], + CHANGED_CODE: [{ secretlyDo: "setCode" }], + CLEARED_ERROR: { if: "hasError", do: "clearError" }, + TOGGLED_DOCS: { to: "viewingDocs" }, + }, + }, + viewingDocs: { + on: { + TOGGLED_DOCS: { to: "editingCode" }, + }, + }, + }, + conditions: { + hasError(data) { + return !!data.error + }, + }, + actions: { + loadFile(data, payload: { file: CodeFile }) { + data.code = payload.file.code + }, + setCode(data, payload: { code: string }) { + data.code = payload.code + }, + runCode(data) { + let error = null + + // try { + // const { nodes, globs } = evalCode(data.code) + // state.send("GENERATED_ITEMS", { nodes, globs }) + // } catch (e) { + // error = { message: e.message, ...getErrorLineAndColumn(e) } + // } + + data.error = error + }, + saveCode(data) { + state.send("CHANGED_CODE", { fileId, code: data.code }) + }, + clearError(data) { + data.error = null + }, + }, + }) + + useEffect(() => { + local.send("CHANGED_FILE", { file }) + }, [file]) + + useEffect(() => { + local.send("MOUNTED", { code: state.data.document.code[fileId].code }) + return () => { + state.send("CHANGED_CODE", { fileId, code: local.data.code }) + } + }, []) + + const { error } = local.data + + return ( + + {isOpen ? ( + +
+ state.send("CLOSED_CODE_PANEL")}> + + +

Code

+ + + state.send("INCREASED_CODE_FONT_SIZE")} + > + + + state.send("DECREASED_CODE_FONT_SIZE")} + > + + + + local.send("TOGGLED_DOCS")}> + + + local.send("SAVED_CODE")} + > + + + +
+ + local.send("CHANGED_CODE", { code })} + onSave={() => local.send("SAVED_CODE")} + onKey={() => local.send("CLEARED_ERROR")} + /> + + + + {error && + (error.line + ? `(${Number(error.line) - 2}:${error.column}) ${error.message}` + : error.message)} + +
+ ) : ( + state.send("OPENED_CODE_PANEL")}> + + + )} +
+ ) +} + +const PanelContainer = styled(motion.div, { + position: "absolute", + top: "8px", + right: "8px", + bottom: "8px", + backgroundColor: "$panel", + borderRadius: "4px", + overflow: "hidden", + border: "1px solid $border", + pointerEvents: "all", + userSelect: "none", + zIndex: 200, + + button: { + border: "none", + }, + + variants: { + isCollapsed: { + true: {}, + false: { + height: "400px", + }, + }, + }, +}) + +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", + minWidth: "100%", + width: 560, + maxWidth: 560, + overflow: "hidden", + height: "100%", + 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, + + "& > button": { + height: "50%", + width: "100%", + + "&:nth-of-type(1)": { + paddingTop: 4, + }, + + "&:nth-of-type(2)": { + paddingBottom: 4, + }, + + "& svg": { + height: 12, + }, + }, +}) diff --git a/components/code-panel/example-code.ts b/components/code-panel/example-code.ts new file mode 100644 index 000000000..e49765e90 --- /dev/null +++ b/components/code-panel/example-code.ts @@ -0,0 +1,47 @@ +export default `// Basic nodes and globs + +const nodeA = new Node({ + x: -100, + y: 0, +}); + +const nodeB = new Node({ + x: 100, + y: 0, +}); + +const glob = new Glob({ + start: nodeA, + end: nodeB, + D: { x: 0, y: 60 }, + Dp: { x: 0, y: 90 }, +}); + +// Something more interesting... + +const PI2 = Math.PI * 2, + center = { x: 0, y: 0 }, + radius = 400; + +let prev; + +for (let i = 0; i < 21; i++) { + const t = i * (PI2 / 20); + + const node = new Node({ + x: center.x + radius * Math.sin(t), + y: center.y + radius * Math.cos(t), + }); + + if (prev !== undefined) { + new Glob({ + start: prev, + end: node, + D: center, + Dp: center, + }); + } + + prev = node; +} +` diff --git a/components/editor.tsx b/components/editor.tsx index 6fc587bf6..0d58d7ea3 100644 --- a/components/editor.tsx +++ b/components/editor.tsx @@ -1,6 +1,7 @@ import useKeyboardEvents from "hooks/useKeyboardEvents" import Canvas from "./canvas/canvas" import StatusBar from "./status-bar" +import CodePanel from "./code-panel/code-panel" export default function Editor() { useKeyboardEvents() @@ -9,6 +10,7 @@ export default function Editor() { <> + {/* */} ) } diff --git a/hooks/useTheme.ts b/hooks/useTheme.ts new file mode 100644 index 000000000..5dcf044ee --- /dev/null +++ b/hooks/useTheme.ts @@ -0,0 +1,12 @@ +import { useCallback } from "react" +import state, { useSelector } from "state" + +export default function useTheme() { + const theme = useSelector((state) => + state.data.settings.darkMode ? "dark" : "light" + ) + + const toggleTheme = useCallback(() => state.send("TOGGLED_THEME"), []) + + return { theme, toggleTheme } +} diff --git a/lib/code/circle.ts b/lib/code/circle.ts new file mode 100644 index 000000000..7f010073c --- /dev/null +++ b/lib/code/circle.ts @@ -0,0 +1,24 @@ +import CodeShape from "./index" +import { v4 as uuid } from "uuid" +import { CircleShape, ShapeType } from "types" + +export default class Circle extends CodeShape { + constructor(props = {} as Partial) { + super({ + id: uuid(), + type: ShapeType.Circle, + name: "Circle", + parentId: "page0", + childIndex: 0, + point: [0, 0], + rotation: 0, + radius: 20, + style: {}, + ...props, + }) + } + + get radius() { + return this.shape.radius + } +} diff --git a/lib/code/index.ts b/lib/code/index.ts new file mode 100644 index 000000000..05be00788 --- /dev/null +++ b/lib/code/index.ts @@ -0,0 +1,54 @@ +import { Shape } from "types" +import * as vec from "utils/vec" +import { getShapeUtils } from "lib/shapes" + +export default class CodeShape { + private _shape: T + + constructor(props: T) { + this._shape = props + shapeMap.add(this) + } + + destroy() { + shapeMap.delete(this) + } + + moveTo(point: number[]) { + this.shape.point = point + } + + translate(delta: number[]) { + this.shape.point = vec.add(this._shape.point, delta) + } + + rotate(rotation: number) { + this.shape.rotation = rotation + } + + scale(scale: number) { + return getShapeUtils(this.shape).scale(this.shape, scale) + } + + getBounds() { + return getShapeUtils(this.shape).getBounds(this.shape) + } + + hitTest(point: number[]) { + return getShapeUtils(this.shape).hitTest(this.shape, point) + } + + get shape() { + return this._shape + } + + get point() { + return [...this.shape.point] + } + + get rotation() { + return this.shape.rotation + } +} + +export const shapeMap = new Set>([]) diff --git a/lib/code/rectangle.ts b/lib/code/rectangle.ts new file mode 100644 index 000000000..7f85d9d7d --- /dev/null +++ b/lib/code/rectangle.ts @@ -0,0 +1,24 @@ +import CodeShape from "./index" +import { v4 as uuid } from "uuid" +import { RectangleShape, ShapeType } from "types" + +export default class Rectangle extends CodeShape { + constructor(props = {} as Partial) { + super({ + id: uuid(), + type: ShapeType.Rectangle, + name: "Rectangle", + parentId: "page0", + childIndex: 0, + point: [0, 0], + size: [1, 1], + rotation: 0, + style: {}, + ...props, + }) + } + + get size() { + return this.shape.size + } +} diff --git a/lib/shapes/circle.tsx b/lib/shapes/circle.tsx index 1956409a3..171a5d19d 100644 --- a/lib/shapes/circle.tsx +++ b/lib/shapes/circle.tsx @@ -4,6 +4,7 @@ import { CircleShape, ShapeType } from "types" import { createShape } from "./index" import { boundsContained } from "utils/bounds" import { intersectCircleBounds } from "utils/intersections" +import { pointInCircle } from "utils/hitTests" const circle = createShape({ boundsCache: new WeakMap([]), @@ -16,8 +17,8 @@ const circle = createShape({ parentId: "page0", childIndex: 0, point: [0, 0], - radius: 20, rotation: 0, + radius: 20, style: {}, ...props, } @@ -51,9 +52,11 @@ const circle = createShape({ return bounds }, - hitTest(shape, test) { - return ( - vec.dist(vec.addScalar(shape.point, shape.radius), test) < shape.radius + hitTest(shape, point) { + return pointInCircle( + point, + vec.addScalar(shape.point, shape.radius), + shape.radius ) }, diff --git a/lib/shapes/dot.tsx b/lib/shapes/dot.tsx index 27d0e362d..a8eccfedc 100644 --- a/lib/shapes/dot.tsx +++ b/lib/shapes/dot.tsx @@ -50,7 +50,7 @@ const dot = createShape({ }, hitTest(shape, test) { - return vec.dist(shape.point, test) < 4 + return true }, hitTestBounds(this, shape, brushBounds) { diff --git a/lib/shapes/ellipse.tsx b/lib/shapes/ellipse.tsx index bb00a9da6..6963616d0 100644 --- a/lib/shapes/ellipse.tsx +++ b/lib/shapes/ellipse.tsx @@ -57,7 +57,12 @@ const ellipse = createShape({ }, hitTest(shape, point) { - return pointInEllipse(point, shape.point, shape.radiusX, shape.radiusY) + return pointInEllipse( + point, + vec.add(shape.point, [shape.radiusX, shape.radiusY]), + shape.radiusX, + shape.radiusY + ) }, hitTestBounds(this, shape, brushBounds) { diff --git a/lib/shapes/line.tsx b/lib/shapes/line.tsx index e3ebcea4f..12b7bbb75 100644 --- a/lib/shapes/line.tsx +++ b/lib/shapes/line.tsx @@ -59,7 +59,7 @@ const line = createShape({ }, hitTest(shape, test) { - return vec.dist(shape.point, test) < 4 + return true }, hitTestBounds(this, shape, brushBounds) { diff --git a/lib/shapes/polyline.tsx b/lib/shapes/polyline.tsx index efb5cbab9..745fa323f 100644 --- a/lib/shapes/polyline.tsx +++ b/lib/shapes/polyline.tsx @@ -57,8 +57,19 @@ const polyline = createShape({ return bounds }, - hitTest(shape) { - return true + hitTest(shape, point) { + let pt = vec.sub(point, shape.point) + let prev = shape.points[0] + + for (let i = 1; i < shape.points.length; i++) { + let curr = shape.points[i] + if (vec.distanceToLineSegment(prev, curr, pt) < 4) { + return true + } + prev = curr + } + + return false }, hitTestBounds(this, shape, bounds) { diff --git a/package.json b/package.json index 6a4f1a93f..51143f3c1 100644 --- a/package.json +++ b/package.json @@ -8,20 +8,24 @@ "start": "next start" }, "dependencies": { + "@monaco-editor/react": "^4.1.3", "@state-designer/react": "^1.7.1", "@stitches/react": "^0.1.9", "@types/uuid": "^8.3.0", "framer-motion": "^4.1.16", "next": "10.2.0", "perfect-freehand": "^0.4.7", + "prettier": "^2.3.0", "react": "17.0.2", "react-dom": "17.0.2", + "react-feather": "^2.0.9", "uuid": "^8.3.2" }, "devDependencies": { "@types/next": "^9.0.0", "@types/react": "^17.0.5", "@types/react-dom": "^17.0.3", + "monaco-editor": "^0.24.0", "typescript": "^4.2.4" } } diff --git a/state/data.ts b/state/data.ts index 98356c1b2..af3b6453f 100644 --- a/state/data.ts +++ b/state/data.ts @@ -90,4 +90,11 @@ export const defaultDocument: Data["document"] = { }, }, }, + code: { + file0: { + id: "file0", + name: "index.ts", + code: "// Hello world", + }, + }, } diff --git a/state/inputs.tsx b/state/inputs.tsx index edf77ebc9..18332a84a 100644 --- a/state/inputs.tsx +++ b/state/inputs.tsx @@ -23,6 +23,23 @@ class Inputs { return info } + pointerEnter(e: PointerEvent | React.PointerEvent, target: string) { + const { shiftKey, ctrlKey, metaKey, altKey } = e + + const info = { + target, + pointerId: e.pointerId, + origin: [e.clientX, e.clientY], + point: [e.clientX, e.clientY], + shiftKey, + ctrlKey, + metaKey: isDarwin() ? metaKey : ctrlKey, + altKey, + } + + return info + } + pointerMove(e: PointerEvent | React.PointerEvent) { const { shiftKey, ctrlKey, metaKey, altKey } = e diff --git a/state/state.ts b/state/state.ts index 694556837..5ea881a2b 100644 --- a/state/state.ts +++ b/state/state.ts @@ -9,12 +9,17 @@ import * as Sessions from "./sessions" const initialData: Data = { isReadOnly: false, + settings: { + fontSize: 13, + darkMode: false, + }, camera: { point: [0, 0], zoom: 1, }, brush: undefined, pointedId: null, + hoveredId: null, selectedIds: new Set([]), currentPageId: "page0", document: defaultDocument, @@ -45,6 +50,15 @@ const state = createState({ POINTED_BOUNDS: { to: "pointingBounds" }, POINTED_BOUNDS_EDGE: { to: "transformingSelection" }, POINTED_BOUNDS_CORNER: { to: "transformingSelection" }, + MOVED_OVER_SHAPE: { + if: "pointHitsShape", + then: { + unless: "shapeIsHovered", + do: "setHoveredId", + }, + else: { if: "shapeIsHovered", do: "clearHoveredId" }, + }, + UNHOVERED_SHAPE: "clearHoveredId", POINTED_SHAPE: [ "setPointedId", { @@ -135,6 +149,22 @@ const state = createState({ isPressingShiftKey(data, payload: { shiftKey: boolean }) { return payload.shiftKey }, + shapeIsHovered(data, payload: { target: string }) { + return data.hoveredId === payload.target + }, + pointHitsShape(data, payload: { target: string; point: number[] }) { + const shape = + data.document.pages[data.currentPageId].shapes[payload.target] + + console.log( + getShapeUtils(shape).hitTest(shape, screenToWorld(payload.point, data)) + ) + + return getShapeUtils(shape).hitTest( + shape, + screenToWorld(payload.point, data) + ) + }, }, actions: { // History @@ -199,6 +229,12 @@ const state = createState({ }, // Selection + setHoveredId(data, payload: PointerInfo) { + data.hoveredId = payload.target + }, + clearHoveredId(data) { + data.hoveredId = undefined + }, setPointedId(data, payload: PointerInfo) { data.pointedId = payload.target }, diff --git a/styles/stitches.config.ts b/styles/stitches.config.ts index 26f3bf10f..0f570db2a 100644 --- a/styles/stitches.config.ts +++ b/styles/stitches.config.ts @@ -12,6 +12,9 @@ const { styled, global, css, theme, getCssString } = createCss({ selected: "rgba(66, 133, 244, 1.000)", bounds: "rgba(65, 132, 244, 1.000)", boundsBg: "rgba(65, 132, 244, 0.100)", + border: "#aaa", + panel: "#fefefe", + text: "#000", }, space: {}, fontSizes: { @@ -22,7 +25,7 @@ const { styled, global, css, theme, getCssString } = createCss({ 4: "18px", }, fonts: { - ui: `"Recursive", system-ui, sans-serif`, + ui: '"Recursive", system-ui, sans-serif', }, fontWeights: {}, lineHeights: {}, diff --git a/types.ts b/types.ts index 7cd7849c1..96207086c 100644 --- a/types.ts +++ b/types.ts @@ -1,7 +1,13 @@ +import * as monaco from "monaco-editor/esm/vs/editor/editor.api" + import React from "react" export interface Data { isReadOnly: boolean + settings: { + fontSize: number + darkMode: boolean + } camera: { point: number[] zoom: number @@ -10,11 +16,19 @@ export interface Data { currentPageId: string selectedIds: Set pointedId?: string + hoveredId?: string document: { pages: Record + code: Record } } +export interface CodeFile { + id: string + name: string + code: string +} + export interface Page { id: string type: "page" @@ -172,3 +186,7 @@ export enum TransformCorner { BottomRight = "bottom_right_corner", BottomLeft = "bottom_left_corner", } + +export type IMonaco = typeof monaco + +export type IMonacoEditor = monaco.editor.IStandaloneCodeEditor diff --git a/yarn.lock b/yarn.lock index 686bd8ba7..245de2e7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1155,6 +1155,22 @@ "@types/yargs" "^15.0.0" chalk "^3.0.0" +"@monaco-editor/loader@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.0.1.tgz#7068c9b07bbc65387c0e7a4df6dac0a326155905" + integrity sha512-hycGOhLqLYjnD0A/FHs56covEQWnDFrSnm/qLKkB/yoeayQ7ju+Vaj4SdTojGrXeY6jhMDx59map0+Jqwquh1Q== + dependencies: + state-local "^1.0.6" + +"@monaco-editor/react@^4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@monaco-editor/react/-/react-4.1.3.tgz#7dcaa584f2a4e8bd8f5298604f0b5368f8ebca55" + integrity sha512-kqcjVuoy6btcgALAk4RV/SlasveM+WTw5lzzlyq5FhKXjF8wu5tSe/2oCQ1uhLpcdtxcHfx3L0HrcAPWnejFnQ== + dependencies: + "@monaco-editor/loader" "^1.0.1" + prop-types "^15.7.2" + state-local "^1.0.7" + "@next/env@10.2.0": version "10.2.0" resolved "https://registry.yarnpkg.com/@next/env/-/env-10.2.0.tgz#154dbce2efa3ad067ebd20b7d0aa9aed775e7c97" @@ -5041,6 +5057,11 @@ mkdirp@0.x, mkdirp@^0.5.1: dependencies: minimist "^1.2.5" +monaco-editor@^0.24.0: + version "0.24.0" + resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.24.0.tgz#990b55096bcc95d08d8d28e55264c6eb17707269" + integrity sha512-o1f0Lz6ABFNTtnEqqqvlY9qzNx24rQZx1RgYNQ8SkWkE+Ka63keHH/RqxQ4QhN4fs/UYOnvAtEUZsPrzccH++A== + mri@^1.1.0: version "1.1.6" resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.6.tgz#49952e1044db21dbf90f6cd92bc9c9a777d415a6" @@ -5700,6 +5721,11 @@ prettier@^1.19.1: resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== +prettier@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.0.tgz#b6a5bf1284026ae640f17f7ff5658a7567fc0d18" + integrity sha512-kXtO4s0Lz/DW/IJ9QdWhAf7/NmPWQXkFr/r/WkR3vyI+0v8amTDxiaQSLzs8NBlytfLWX/7uQUMIW677yLKl4w== + pretty-format@^25.2.1, pretty-format@^25.5.0: version "25.5.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-25.5.0.tgz#7873c1d774f682c34b8d48b6743a2bf2ac55791a" @@ -5846,6 +5872,13 @@ react-dom@17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-feather@^2.0.9: + version "2.0.9" + resolved "https://registry.yarnpkg.com/react-feather/-/react-feather-2.0.9.tgz#6e42072130d2fa9a09d4476b0e61b0ed17814480" + integrity sha512-yMfCGRkZdXwIs23Zw/zIWCJO3m3tlaUvtHiXlW+3FH7cIT6fiK1iJ7RJWugXq7Fso8ZaQyUm92/GOOHXvkiVUw== + dependencies: + prop-types "^15.7.2" + react-is@16.13.1, react-is@^16.12.0, react-is@^16.8.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -6591,6 +6624,11 @@ stacktrace-parser@0.1.10: dependencies: type-fest "^0.7.1" +state-local@^1.0.6, state-local@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/state-local/-/state-local-1.0.7.tgz#da50211d07f05748d53009bee46307a37db386d5" + integrity sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w== + static-extend@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"