adds hit testing for hovers

This commit is contained in:
Steve Ruiz 2021-05-14 23:56:41 +01:00
parent b8d3b35b07
commit afa8f53dff
25 changed files with 1034 additions and 83 deletions

View file

@ -16,8 +16,8 @@ export default function BoundsBg() {
ref={rBounds} ref={rBounds}
x={minX} x={minX}
y={minY} y={minY}
width={width} width={Math.max(1, width)}
height={height} height={Math.max(1, height)}
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.buttons !== 1) return if (e.buttons !== 1) return
e.stopPropagation() e.stopPropagation()

View file

@ -16,8 +16,6 @@ export default function Bounds() {
const p = 4 / zoom const p = 4 / zoom
const cp = p * 2 const cp = p * 2
if (width < p || height < p) return null
return ( return (
<g pointerEvents={isBrushing ? "none" : "all"}> <g pointerEvents={isBrushing ? "none" : "all"}>
<StyledBounds <StyledBounds
@ -27,66 +25,62 @@ export default function Bounds() {
height={height} height={height}
pointerEvents="none" pointerEvents="none"
/> />
{width * zoom > 8 && height * zoom > 8 && ( <EdgeHorizontal
<> x={minX + p}
<EdgeHorizontal y={minY}
x={minX + p} width={Math.max(0, width - p * 2)}
y={minY} height={p}
width={Math.max(0, width - p * 2)} edge={TransformEdge.Top}
height={p} />
edge={TransformEdge.Top} <EdgeVertical
/> x={maxX}
<EdgeVertical y={minY + p}
x={maxX} width={p}
y={minY + p} height={Math.max(0, height - p * 2)}
width={p} edge={TransformEdge.Right}
height={Math.max(0, height - p * 2)} />
edge={TransformEdge.Right} <EdgeHorizontal
/> x={minX + p}
<EdgeHorizontal y={maxY}
x={minX + p} width={Math.max(0, width - p * 2)}
y={maxY} height={p}
width={Math.max(0, width - p * 2)} edge={TransformEdge.Bottom}
height={p} />
edge={TransformEdge.Bottom} <EdgeVertical
/> x={minX}
<EdgeVertical y={minY + p}
x={minX} width={p}
y={minY + p} height={Math.max(0, height - p * 2)}
width={p} edge={TransformEdge.Left}
height={Math.max(0, height - p * 2)} />
edge={TransformEdge.Left} <Corner
/> x={minX}
<Corner y={minY}
x={minX} width={cp}
y={minY} height={cp}
width={cp} corner={TransformCorner.TopLeft}
height={cp} />
corner={TransformCorner.TopLeft} <Corner
/> x={maxX}
<Corner y={minY}
x={maxX} width={cp}
y={minY} height={cp}
width={cp} corner={TransformCorner.TopRight}
height={cp} />
corner={TransformCorner.TopRight} <Corner
/> x={maxX}
<Corner y={maxY}
x={maxX} width={cp}
y={maxY} height={cp}
width={cp} corner={TransformCorner.BottomRight}
height={cp} />
corner={TransformCorner.BottomRight} <Corner
/> x={minX}
<Corner y={maxY}
x={minX} width={cp}
y={maxY} height={cp}
width={cp} corner={TransformCorner.BottomLeft}
height={cp} />
corner={TransformCorner.BottomLeft}
/>
</>
)}
</g> </g>
) )
} }
@ -142,6 +136,7 @@ function Corner({
rCorner.current.setPointerCapture(e.pointerId) rCorner.current.setPointerCapture(e.pointerId)
state.send("POINTED_BOUNDS_CORNER", inputs.pointerDown(e, corner)) state.send("POINTED_BOUNDS_CORNER", inputs.pointerDown(e, corner))
}} }}
onPointerCancelCapture={() => console.log("oops")}
onPointerUp={(e) => { onPointerUp={(e) => {
e.stopPropagation() e.stopPropagation()
rCorner.current.releasePointerCapture(e.pointerId) rCorner.current.releasePointerCapture(e.pointerId)

View file

@ -7,6 +7,7 @@ import styled from "styles"
function Shape({ id }: { id: string }) { function Shape({ id }: { id: string }) {
const rGroup = useRef<SVGGElement>(null) const rGroup = useRef<SVGGElement>(null)
const isHovered = useSelector((state) => state.data.hoveredId === id)
const isSelected = useSelector((state) => state.values.selectedIds.has(id)) const isSelected = useSelector((state) => state.values.selectedIds.has(id))
const shape = useSelector( const shape = useSelector(
@ -32,23 +33,35 @@ function Shape({ id }: { id: string }) {
) )
const handlePointerEnter = useCallback( const handlePointerEnter = useCallback(
() => state.send("HOVERED_SHAPE", { id }), (e: React.PointerEvent) => {
[id] 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( const handlePointerLeave = useCallback(
() => state.send("UNHOVERED_SHAPE", { id }), () => state.send("UNHOVERED_SHAPE", { target: id }),
[id] [id]
) )
return ( return (
<StyledGroup <StyledGroup
ref={rGroup} ref={rGroup}
isHovered={isHovered}
isSelected={isSelected} isSelected={isSelected}
transform={`translate(${shape.point})`} transform={`translate(${shape.point})`}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerEnter={handlePointerEnter} onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave} onPointerLeave={handlePointerLeave}
onPointerMove={handlePointerMove}
> >
<defs>{getShapeUtils(shape).render(shape)}</defs> <defs>{getShapeUtils(shape).render(shape)}</defs>
<HoverIndicator as="use" xlinkHref={"#" + id} /> <HoverIndicator as="use" xlinkHref={"#" + id} />
@ -86,13 +99,12 @@ const StyledGroup = styled("g", {
[`& ${Indicator}`]: { [`& ${Indicator}`]: {
stroke: "$selected", stroke: "$selected",
}, },
[`&:hover ${HoverIndicator}`]: {
opacity: "1",
stroke: "$hint",
},
}, },
false: { false: {},
[`&:hover ${HoverIndicator}`]: { },
isHovered: {
true: {
[`& ${HoverIndicator}`]: {
opacity: "1", opacity: "1",
stroke: "$hint", stroke: "$hint",
}, },

View file

@ -0,0 +1,9 @@
// This is the code library.
export default `
// Hello world
const name = "steve"
const age = 93
`

View file

@ -0,0 +1,101 @@
import styled from "styles"
export default function CodeDocs({ isHidden }: { isHidden: boolean }) {
return (
<StyledDocs isHidden={isHidden}>
<h2>Docs</h2>
</StyledDocs>
)
}
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,
},
})

View file

@ -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<IMonaco>
editorRef?: React.MutableRefObject<IMonacoEditor>
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<IMonacoEditor>(null)
const rMonaco = useRef<IMonaco>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => e.stopPropagation(),
[]
)
const rDecorations = useRef<any>([])
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 (
<EditorContainer onKeyDown={handleKeydown} onKeyUp={handleKeyUp}>
<Editor
height="100%"
language="javascript"
value={value}
theme={theme === "dark" ? "vs-dark" : "light"}
beforeMount={handleBeforeMount}
onMount={handleMount}
onChange={handleChange}
/>
</EditorContainer>
)
}
const EditorContainer = styled("div", {
height: "100%",
pointerEvents: "all",
userSelect: "all",
".editorLineError": {
backgroundColor: "$lineError",
},
})

View file

@ -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<HTMLDivElement>(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 (
<PanelContainer
data-bp-desktop
ref={rContainer}
dragMomentum={false}
isCollapsed={!isOpen}
>
{isOpen ? (
<Content>
<Header>
<IconButton onClick={() => state.send("CLOSED_CODE_PANEL")}>
<X />
</IconButton>
<h3>Code</h3>
<ButtonsGroup>
<FontSizeButtons>
<IconButton
disabled={!local.isIn("editingCode")}
onClick={() => state.send("INCREASED_CODE_FONT_SIZE")}
>
<ChevronUp />
</IconButton>
<IconButton
disabled={!local.isIn("editingCode")}
onClick={() => state.send("DECREASED_CODE_FONT_SIZE")}
>
<ChevronDown />
</IconButton>
</FontSizeButtons>
<IconButton onClick={() => local.send("TOGGLED_DOCS")}>
<Info />
</IconButton>
<IconButton
disabled={!local.isIn("editingCode")}
onClick={() => local.send("SAVED_CODE")}
>
<PlayCircle />
</IconButton>
</ButtonsGroup>
</Header>
<EditorContainer>
<CodeEditor
fontSize={fontSize}
readOnly={isReadOnly}
value={file.code}
error={error}
onChange={(code) => local.send("CHANGED_CODE", { code })}
onSave={() => local.send("SAVED_CODE")}
onKey={() => local.send("CLEARED_ERROR")}
/>
<CodeDocs isHidden={!local.isIn("viewingDocs")} />
</EditorContainer>
<ErrorContainer>
{error &&
(error.line
? `(${Number(error.line) - 2}:${error.column}) ${error.message}`
: error.message)}
</ErrorContainer>
</Content>
) : (
<IconButton onClick={() => state.send("OPENED_CODE_PANEL")}>
<Code />
</IconButton>
)}
</PanelContainer>
)
}
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,
},
},
})

View file

@ -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;
}
`

View file

@ -1,6 +1,7 @@
import useKeyboardEvents from "hooks/useKeyboardEvents" import useKeyboardEvents from "hooks/useKeyboardEvents"
import Canvas from "./canvas/canvas" import Canvas from "./canvas/canvas"
import StatusBar from "./status-bar" import StatusBar from "./status-bar"
import CodePanel from "./code-panel/code-panel"
export default function Editor() { export default function Editor() {
useKeyboardEvents() useKeyboardEvents()
@ -9,6 +10,7 @@ export default function Editor() {
<> <>
<Canvas /> <Canvas />
<StatusBar /> <StatusBar />
{/* <CodePanel /> */}
</> </>
) )
} }

12
hooks/useTheme.ts Normal file
View file

@ -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 }
}

24
lib/code/circle.ts Normal file
View file

@ -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<CircleShape> {
constructor(props = {} as Partial<CircleShape>) {
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
}
}

54
lib/code/index.ts Normal file
View file

@ -0,0 +1,54 @@
import { Shape } from "types"
import * as vec from "utils/vec"
import { getShapeUtils } from "lib/shapes"
export default class CodeShape<T extends Shape> {
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<CodeShape<Shape>>([])

24
lib/code/rectangle.ts Normal file
View file

@ -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<RectangleShape> {
constructor(props = {} as Partial<RectangleShape>) {
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
}
}

View file

@ -4,6 +4,7 @@ import { CircleShape, ShapeType } from "types"
import { createShape } from "./index" import { createShape } from "./index"
import { boundsContained } from "utils/bounds" import { boundsContained } from "utils/bounds"
import { intersectCircleBounds } from "utils/intersections" import { intersectCircleBounds } from "utils/intersections"
import { pointInCircle } from "utils/hitTests"
const circle = createShape<CircleShape>({ const circle = createShape<CircleShape>({
boundsCache: new WeakMap([]), boundsCache: new WeakMap([]),
@ -16,8 +17,8 @@ const circle = createShape<CircleShape>({
parentId: "page0", parentId: "page0",
childIndex: 0, childIndex: 0,
point: [0, 0], point: [0, 0],
radius: 20,
rotation: 0, rotation: 0,
radius: 20,
style: {}, style: {},
...props, ...props,
} }
@ -51,9 +52,11 @@ const circle = createShape<CircleShape>({
return bounds return bounds
}, },
hitTest(shape, test) { hitTest(shape, point) {
return ( return pointInCircle(
vec.dist(vec.addScalar(shape.point, shape.radius), test) < shape.radius point,
vec.addScalar(shape.point, shape.radius),
shape.radius
) )
}, },

View file

@ -50,7 +50,7 @@ const dot = createShape<DotShape>({
}, },
hitTest(shape, test) { hitTest(shape, test) {
return vec.dist(shape.point, test) < 4 return true
}, },
hitTestBounds(this, shape, brushBounds) { hitTestBounds(this, shape, brushBounds) {

View file

@ -57,7 +57,12 @@ const ellipse = createShape<EllipseShape>({
}, },
hitTest(shape, point) { 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) { hitTestBounds(this, shape, brushBounds) {

View file

@ -59,7 +59,7 @@ const line = createShape<LineShape>({
}, },
hitTest(shape, test) { hitTest(shape, test) {
return vec.dist(shape.point, test) < 4 return true
}, },
hitTestBounds(this, shape, brushBounds) { hitTestBounds(this, shape, brushBounds) {

View file

@ -57,8 +57,19 @@ const polyline = createShape<PolylineShape>({
return bounds return bounds
}, },
hitTest(shape) { hitTest(shape, point) {
return true 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) { hitTestBounds(this, shape, bounds) {

View file

@ -8,20 +8,24 @@
"start": "next start" "start": "next start"
}, },
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.1.3",
"@state-designer/react": "^1.7.1", "@state-designer/react": "^1.7.1",
"@stitches/react": "^0.1.9", "@stitches/react": "^0.1.9",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"framer-motion": "^4.1.16", "framer-motion": "^4.1.16",
"next": "10.2.0", "next": "10.2.0",
"perfect-freehand": "^0.4.7", "perfect-freehand": "^0.4.7",
"prettier": "^2.3.0",
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-feather": "^2.0.9",
"uuid": "^8.3.2" "uuid": "^8.3.2"
}, },
"devDependencies": { "devDependencies": {
"@types/next": "^9.0.0", "@types/next": "^9.0.0",
"@types/react": "^17.0.5", "@types/react": "^17.0.5",
"@types/react-dom": "^17.0.3", "@types/react-dom": "^17.0.3",
"monaco-editor": "^0.24.0",
"typescript": "^4.2.4" "typescript": "^4.2.4"
} }
} }

View file

@ -90,4 +90,11 @@ export const defaultDocument: Data["document"] = {
}, },
}, },
}, },
code: {
file0: {
id: "file0",
name: "index.ts",
code: "// Hello world",
},
},
} }

View file

@ -23,6 +23,23 @@ class Inputs {
return info 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) { pointerMove(e: PointerEvent | React.PointerEvent) {
const { shiftKey, ctrlKey, metaKey, altKey } = e const { shiftKey, ctrlKey, metaKey, altKey } = e

View file

@ -9,12 +9,17 @@ import * as Sessions from "./sessions"
const initialData: Data = { const initialData: Data = {
isReadOnly: false, isReadOnly: false,
settings: {
fontSize: 13,
darkMode: false,
},
camera: { camera: {
point: [0, 0], point: [0, 0],
zoom: 1, zoom: 1,
}, },
brush: undefined, brush: undefined,
pointedId: null, pointedId: null,
hoveredId: null,
selectedIds: new Set([]), selectedIds: new Set([]),
currentPageId: "page0", currentPageId: "page0",
document: defaultDocument, document: defaultDocument,
@ -45,6 +50,15 @@ const state = createState({
POINTED_BOUNDS: { to: "pointingBounds" }, POINTED_BOUNDS: { to: "pointingBounds" },
POINTED_BOUNDS_EDGE: { to: "transformingSelection" }, POINTED_BOUNDS_EDGE: { to: "transformingSelection" },
POINTED_BOUNDS_CORNER: { 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: [ POINTED_SHAPE: [
"setPointedId", "setPointedId",
{ {
@ -135,6 +149,22 @@ const state = createState({
isPressingShiftKey(data, payload: { shiftKey: boolean }) { isPressingShiftKey(data, payload: { shiftKey: boolean }) {
return payload.shiftKey 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: { actions: {
// History // History
@ -199,6 +229,12 @@ const state = createState({
}, },
// Selection // Selection
setHoveredId(data, payload: PointerInfo) {
data.hoveredId = payload.target
},
clearHoveredId(data) {
data.hoveredId = undefined
},
setPointedId(data, payload: PointerInfo) { setPointedId(data, payload: PointerInfo) {
data.pointedId = payload.target data.pointedId = payload.target
}, },

View file

@ -12,6 +12,9 @@ const { styled, global, css, theme, getCssString } = createCss({
selected: "rgba(66, 133, 244, 1.000)", selected: "rgba(66, 133, 244, 1.000)",
bounds: "rgba(65, 132, 244, 1.000)", bounds: "rgba(65, 132, 244, 1.000)",
boundsBg: "rgba(65, 132, 244, 0.100)", boundsBg: "rgba(65, 132, 244, 0.100)",
border: "#aaa",
panel: "#fefefe",
text: "#000",
}, },
space: {}, space: {},
fontSizes: { fontSizes: {
@ -22,7 +25,7 @@ const { styled, global, css, theme, getCssString } = createCss({
4: "18px", 4: "18px",
}, },
fonts: { fonts: {
ui: `"Recursive", system-ui, sans-serif`, ui: '"Recursive", system-ui, sans-serif',
}, },
fontWeights: {}, fontWeights: {},
lineHeights: {}, lineHeights: {},

View file

@ -1,7 +1,13 @@
import * as monaco from "monaco-editor/esm/vs/editor/editor.api"
import React from "react" import React from "react"
export interface Data { export interface Data {
isReadOnly: boolean isReadOnly: boolean
settings: {
fontSize: number
darkMode: boolean
}
camera: { camera: {
point: number[] point: number[]
zoom: number zoom: number
@ -10,11 +16,19 @@ export interface Data {
currentPageId: string currentPageId: string
selectedIds: Set<string> selectedIds: Set<string>
pointedId?: string pointedId?: string
hoveredId?: string
document: { document: {
pages: Record<string, Page> pages: Record<string, Page>
code: Record<string, CodeFile>
} }
} }
export interface CodeFile {
id: string
name: string
code: string
}
export interface Page { export interface Page {
id: string id: string
type: "page" type: "page"
@ -172,3 +186,7 @@ export enum TransformCorner {
BottomRight = "bottom_right_corner", BottomRight = "bottom_right_corner",
BottomLeft = "bottom_left_corner", BottomLeft = "bottom_left_corner",
} }
export type IMonaco = typeof monaco
export type IMonacoEditor = monaco.editor.IStandaloneCodeEditor

View file

@ -1155,6 +1155,22 @@
"@types/yargs" "^15.0.0" "@types/yargs" "^15.0.0"
chalk "^3.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": "@next/env@10.2.0":
version "10.2.0" version "10.2.0"
resolved "https://registry.yarnpkg.com/@next/env/-/env-10.2.0.tgz#154dbce2efa3ad067ebd20b7d0aa9aed775e7c97" 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: dependencies:
minimist "^1.2.5" 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: mri@^1.1.0:
version "1.1.6" version "1.1.6"
resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.6.tgz#49952e1044db21dbf90f6cd92bc9c9a777d415a6" 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" resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb"
integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== 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: pretty-format@^25.2.1, pretty-format@^25.5.0:
version "25.5.0" version "25.5.0"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-25.5.0.tgz#7873c1d774f682c34b8d48b6743a2bf2ac55791a" 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" object-assign "^4.1.1"
scheduler "^0.20.2" 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: react-is@16.13.1, react-is@^16.12.0, react-is@^16.8.1:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@ -6591,6 +6624,11 @@ stacktrace-parser@0.1.10:
dependencies: dependencies:
type-fest "^0.7.1" 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: static-extend@^0.1.1:
version "0.1.2" version "0.1.2"
resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"