Adds tool lock

This commit is contained in:
Steve Ruiz 2021-05-28 21:30:27 +01:00
parent c95c54dae6
commit fe3980c80c
28 changed files with 1136 additions and 738 deletions

View file

@ -23,9 +23,9 @@ function Shape({ id, isSelecting }: { id: string; isSelecting: boolean }) {
// detects the change and pulls this component. // detects the change and pulls this component.
if (!shape) return null if (!shape) return null
const center = getShapeUtils(shape).getCenter(shape)
const transform = ` const transform = `
rotate(${shape.rotation * (180 / Math.PI)}, rotate(${shape.rotation * (180 / Math.PI)}, ${center})
${getShapeUtils(shape).getCenter(shape)})
translate(${shape.point})` translate(${shape.point})`
return ( return (
@ -38,18 +38,37 @@ function Shape({ id, isSelecting }: { id: string; isSelecting: boolean }) {
> >
{isSelecting && <HoverIndicator as="use" href={'#' + id} />} {isSelecting && <HoverIndicator as="use" href={'#' + id} />}
<StyledShape id={id} style={shape.style} /> <StyledShape id={id} style={shape.style} />
{/*
<text
y={4}
x={4}
fontSize={18}
fill="black"
stroke="none"
alignmentBaseline="text-before-edge"
pointerEvents="none"
>
{center.toString()}
</text> */}
</StyledGroup> </StyledGroup>
) )
} }
const StyledShape = memo( const StyledShape = memo(
({ id, style }: { id: string; style: ShapeStyles }) => { ({ id, style }: { id: string; style: ShapeStyles }) => {
return <MainShape as="use" href={'#' + id} {...style} /> return (
<MainShape
as="use"
href={'#' + id}
{...style}
// css={{ zStrokeWidth: Number(style.strokeWidth) }}
/>
)
} }
) )
const MainShape = styled('use', { const MainShape = styled('use', {
zStrokeWidth: 1, // zStrokeWidth: 1,
}) })
const HoverIndicator = styled('path', { const HoverIndicator = styled('path', {

View file

@ -1,20 +1,20 @@
import styled from "styles" import styled from 'styles'
export const Root = styled("div", { export const Root = styled('div', {
position: "relative", position: 'relative',
backgroundColor: "$panel", backgroundColor: '$panel',
borderRadius: "4px", borderRadius: '4px',
overflow: "hidden", overflow: 'hidden',
border: "1px solid $border", border: '1px solid $border',
pointerEvents: "all", pointerEvents: 'all',
userSelect: "none", userSelect: 'none',
zIndex: 200, zIndex: 200,
boxShadow: "0px 2px 25px rgba(0,0,0,.16)", boxShadow: '0px 2px 25px rgba(0,0,0,.16)',
variants: { variants: {
isOpen: { isOpen: {
true: { true: {
width: "auto", width: 'auto',
minWidth: 300, minWidth: 300,
}, },
false: { false: {
@ -25,63 +25,74 @@ export const Root = styled("div", {
}, },
}) })
export const Layout = styled("div", { export const Layout = styled('div', {
display: "grid", display: 'grid',
gridTemplateColumns: "1fr", gridTemplateColumns: '1fr',
gridTemplateRows: "auto 1fr", gridTemplateRows: 'auto 1fr',
gridAutoRows: "28px", gridAutoRows: '28px',
height: "100%", height: '100%',
width: "auto", width: 'auto',
minWidth: "100%", minWidth: '100%',
maxWidth: 560, maxWidth: 560,
overflow: "hidden", overflow: 'hidden',
userSelect: "none", userSelect: 'none',
pointerEvents: "all", pointerEvents: 'all',
}) })
export const Header = styled("div", { export const Header = styled('div', {
pointerEvents: "all", pointerEvents: 'all',
display: "flex", display: 'flex',
width: "100%", width: '100%',
alignItems: "center", alignItems: 'center',
justifyContent: "space-between", justifyContent: 'space-between',
borderBottom: "1px solid $border", borderBottom: '1px solid $border',
position: "relative", position: 'relative',
"& h3": { '& h3': {
position: "absolute", position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
width: "100%", width: '100%',
height: "100%", height: '100%',
textAlign: "center", textAlign: 'center',
padding: 0, padding: 0,
margin: 0, margin: 0,
display: "flex", display: 'flex',
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
fontSize: "13px", fontSize: '13px',
pointerEvents: "none", pointerEvents: 'none',
userSelect: "none", userSelect: 'none',
},
variants: {
side: {
left: {
flexDirection: 'row',
},
right: {
flexDirection: 'row-reverse',
},
},
}, },
}) })
export const ButtonsGroup = styled("div", { export const ButtonsGroup = styled('div', {
display: "flex", display: 'flex',
}) })
export const Content = styled("div", { export const Content = styled('div', {
position: "relative", position: 'relative',
pointerEvents: "all", pointerEvents: 'all',
overflowY: "scroll", overflowY: 'scroll',
}) })
export const Footer = styled("div", { export const Footer = styled('div', {
overflowX: "scroll", overflowX: 'scroll',
color: "$text", color: '$text',
font: "$debug", font: '$debug',
padding: "0 12px", padding: '0 12px',
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
borderTop: "1px solid $border", borderTop: '1px solid $border',
}) })

View file

@ -9,50 +9,50 @@ import {
SpaceEvenlyVerticallyIcon, SpaceEvenlyVerticallyIcon,
StretchHorizontallyIcon, StretchHorizontallyIcon,
StretchVerticallyIcon, StretchVerticallyIcon,
} from "@radix-ui/react-icons" } from '@radix-ui/react-icons'
import { IconButton } from "components/shared" import { IconButton } from 'components/shared'
import state from "state" import state from 'state'
import styled from "styles" import styled from 'styles'
import { AlignType, DistributeType, StretchType } from "types" import { AlignType, DistributeType, StretchType } from 'types'
function alignTop() { function alignTop() {
state.send("ALIGNED", { type: AlignType.Top }) state.send('ALIGNED', { type: AlignType.Top })
} }
function alignCenterVertical() { function alignCenterVertical() {
state.send("ALIGNED", { type: AlignType.CenterVertical }) state.send('ALIGNED', { type: AlignType.CenterVertical })
} }
function alignBottom() { function alignBottom() {
state.send("ALIGNED", { type: AlignType.Bottom }) state.send('ALIGNED', { type: AlignType.Bottom })
} }
function stretchVertically() { function stretchVertically() {
state.send("STRETCHED", { type: StretchType.Vertical }) state.send('STRETCHED', { type: StretchType.Vertical })
} }
function distributeVertically() { function distributeVertically() {
state.send("DISTRIBUTED", { type: DistributeType.Vertical }) state.send('DISTRIBUTED', { type: DistributeType.Vertical })
} }
function alignLeft() { function alignLeft() {
state.send("ALIGNED", { type: AlignType.Left }) state.send('ALIGNED', { type: AlignType.Left })
} }
function alignCenterHorizontal() { function alignCenterHorizontal() {
state.send("ALIGNED", { type: AlignType.CenterHorizontal }) state.send('ALIGNED', { type: AlignType.CenterHorizontal })
} }
function alignRight() { function alignRight() {
state.send("ALIGNED", { type: AlignType.Right }) state.send('ALIGNED', { type: AlignType.Right })
} }
function stretchHorizontally() { function stretchHorizontally() {
state.send("STRETCHED", { type: StretchType.Horizontal }) state.send('STRETCHED', { type: StretchType.Horizontal })
} }
function distributeHorizontally() { function distributeHorizontally() {
state.send("DISTRIBUTED", { type: DistributeType.Horizontal }) state.send('DISTRIBUTED', { type: DistributeType.Horizontal })
} }
export default function AlignDistribute({ export default function AlignDistribute({
@ -98,15 +98,15 @@ export default function AlignDistribute({
) )
} }
const Container = styled("div", { const Container = styled('div', {
display: "grid", display: 'grid',
padding: 4, padding: 4,
gridTemplateColumns: "repeat(5, auto)", gridTemplateColumns: 'repeat(5, auto)',
[`& ${IconButton}`]: { [`& ${IconButton}`]: {
color: "$text", color: '$text',
}, },
[`& ${IconButton} > svg`]: { [`& ${IconButton} > svg`]: {
fill: "red", fill: 'red',
stroke: "transparent", stroke: 'transparent',
}, },
}) })

View file

@ -1,6 +1,6 @@
import * as DropdownMenu from "@radix-ui/react-dropdown-menu" import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { Square } from "react-feather" import { Square } from 'react-feather'
import styled from "styles" import styled from 'styles'
interface Props { interface Props {
label: string label: string
@ -13,7 +13,7 @@ export default function ColorPicker({ label, color, colors, onChange }: Props) {
return ( return (
<DropdownMenu.Root> <DropdownMenu.Root>
<CurrentColor> <CurrentColor>
<h3>{label}</h3> <label>{label}</label>
<ColorIcon color={color} /> <ColorIcon color={color} />
</CurrentColor> </CurrentColor>
<Colors sideOffset={4}> <Colors sideOffset={4}>
@ -31,96 +31,96 @@ function ColorIcon({ color }: { color: string }) {
return ( return (
<Square <Square
fill={color} fill={color}
strokeDasharray={color === "transparent" ? "2, 3" : "none"} strokeDasharray={color === 'transparent' ? '2, 3' : 'none'}
/> />
) )
} }
const Colors = styled(DropdownMenu.Content, { const Colors = styled(DropdownMenu.Content, {
display: "grid", display: 'grid',
padding: 4, padding: 4,
gridTemplateColumns: "repeat(6, 1fr)", gridTemplateColumns: 'repeat(6, 1fr)',
border: "1px solid $border", border: '1px solid $border',
backgroundColor: "$panel", backgroundColor: '$panel',
borderRadius: 4, borderRadius: 4,
boxShadow: "0px 5px 15px -5px hsla(206,22%,7%,.15)", boxShadow: '0px 5px 15px -5px hsla(206,22%,7%,.15)',
}) })
const ColorButton = styled(DropdownMenu.Item, { const ColorButton = styled(DropdownMenu.Item, {
position: "relative", position: 'relative',
cursor: "pointer", cursor: 'pointer',
height: 32, height: 32,
width: 32, width: 32,
border: "none", border: 'none',
padding: "none", padding: 'none',
background: "none", background: 'none',
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
"&::before": { '&::before': {
content: "''", content: "''",
position: "absolute", position: 'absolute',
top: 4, top: 4,
left: 4, left: 4,
right: 4, right: 4,
bottom: 4, bottom: 4,
pointerEvents: "none", pointerEvents: 'none',
zIndex: 0, zIndex: 0,
}, },
"&:hover::before": { '&:hover::before': {
backgroundColor: "$hover", backgroundColor: '$hover',
borderRadius: 4, borderRadius: 4,
}, },
"& svg": { '& svg': {
position: "relative", position: 'relative',
stroke: "rgba(0,0,0,.2)", stroke: 'rgba(0,0,0,.2)',
strokeWidth: 1, strokeWidth: 1,
zIndex: 1, zIndex: 1,
}, },
}) })
const CurrentColor = styled(DropdownMenu.Trigger, { const CurrentColor = styled(DropdownMenu.Trigger, {
position: "relative", position: 'relative',
display: "flex", display: 'flex',
width: "100%", width: '100%',
background: "none", background: 'none',
border: "none", border: 'none',
cursor: "pointer", cursor: 'pointer',
outline: "none", outline: 'none',
alignItems: "center", alignItems: 'center',
justifyContent: "space-between", justifyContent: 'space-between',
padding: "4px 6px 4px 12px", padding: '4px 6px 4px 12px',
"&::before": { '&::before': {
content: "''", content: "''",
position: "absolute", position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
pointerEvents: "none", pointerEvents: 'none',
zIndex: -1, zIndex: -1,
}, },
"&:hover::before": { '&:hover::before': {
backgroundColor: "$hover", backgroundColor: '$hover',
borderRadius: 4, borderRadius: 4,
}, },
"& h3": { '& label': {
fontFamily: "$ui", fontFamily: '$ui',
fontSize: "$2", fontSize: '$2',
fontWeight: "$1", fontWeight: '$1',
margin: 0, margin: 0,
padding: 0, padding: 0,
}, },
"& svg": { '& svg': {
position: "relative", position: 'relative',
stroke: "rgba(0,0,0,.2)", stroke: 'rgba(0,0,0,.2)',
strokeWidth: 1, strokeWidth: 1,
zIndex: 1, zIndex: 1,
}, },

View file

@ -1,15 +1,17 @@
import styled from "styles" import styled from 'styles'
import state, { useSelector } from "state" import state, { useSelector } from 'state'
import * as Panel from "components/panel" import * as Panel from 'components/panel'
import { useRef } from "react" import { useRef } from 'react'
import { IconButton } from "components/shared" import { IconButton } from 'components/shared'
import { Circle, Trash, X } from "react-feather" import { Circle, Copy, Lock, Trash, Unlock, X } from 'react-feather'
import { deepCompare, deepCompareArrays, getSelectedShapes } from "utils/utils" import { deepCompare, deepCompareArrays, getSelectedShapes } from 'utils/utils'
import { shades, fills, strokes } from "lib/colors" import { shades, fills, strokes } from 'lib/colors'
import ColorPicker from "./color-picker" import ColorPicker from './color-picker'
import AlignDistribute from "./align-distribute" import AlignDistribute from './align-distribute'
import { ShapeStyles } from "types" import { ShapeStyles } from 'types'
import WidthPicker from './width-picker'
import { CopyIcon } from '@radix-ui/react-icons'
const fillColors = { ...shades, ...fills } const fillColors = { ...shades, ...fills }
const strokeColors = { ...shades, ...strokes } const strokeColors = { ...shades, ...strokes }
@ -23,7 +25,7 @@ export default function StylePanel() {
{isOpen ? ( {isOpen ? (
<SelectedShapeStyles /> <SelectedShapeStyles />
) : ( ) : (
<IconButton onClick={() => state.send("TOGGLED_STYLE_PANEL_OPEN")}> <IconButton onClick={() => state.send('TOGGLED_STYLE_PANEL_OPEN')}>
<Circle /> <Circle />
</IconButton> </IconButton>
)} )}
@ -72,17 +74,9 @@ function SelectedShapeStyles({}: {}) {
return ( return (
<Panel.Layout> <Panel.Layout>
<Panel.Header> <Panel.Header side="right">
<h3>Style</h3> <h3>Style</h3>
<Panel.ButtonsGroup> <IconButton onClick={() => state.send('TOGGLED_STYLE_PANEL_OPEN')}>
<IconButton
disabled={!hasSelection}
onClick={() => state.send("DELETED")}
>
<Trash />
</IconButton>
</Panel.ButtonsGroup>
<IconButton onClick={() => state.send("TOGGLED_STYLE_PANEL_OPEN")}>
<X /> <X />
</IconButton> </IconButton>
</Panel.Header> </Panel.Header>
@ -91,18 +85,40 @@ function SelectedShapeStyles({}: {}) {
label="Fill" label="Fill"
color={shapesStyle.fill} color={shapesStyle.fill}
colors={fillColors} colors={fillColors}
onChange={(color) => state.send("CHANGED_STYLE", { fill: color })} onChange={(color) => state.send('CHANGED_STYLE', { fill: color })}
/> />
<ColorPicker <ColorPicker
label="Stroke" label="Stroke"
color={shapesStyle.stroke} color={shapesStyle.stroke}
colors={strokeColors} colors={strokeColors}
onChange={(color) => state.send("CHANGED_STYLE", { stroke: color })} onChange={(color) => state.send('CHANGED_STYLE', { stroke: color })}
/> />
<Row>
<label htmlFor="width">Width</label>
<WidthPicker strokeWidth={Number(shapesStyle.strokeWidth)} />
</Row>
<AlignDistribute <AlignDistribute
hasTwoOrMore={selectedIds.length > 1} hasTwoOrMore={selectedIds.length > 1}
hasThreeOrMore={selectedIds.length > 2} hasThreeOrMore={selectedIds.length > 2}
/> />
<ButtonsRow>
<IconButton
disabled={!hasSelection}
onClick={() => state.send('DELETED')}
>
<Trash />
</IconButton>
<IconButton
disabled={!hasSelection}
onClick={() => state.send('DUPLICATED')}
>
<Copy />
</IconButton>
<IconButton>
<Unlock />
</IconButton>
</ButtonsRow>
</Content> </Content>
</Panel.Layout> </Panel.Layout>
) )
@ -112,8 +128,8 @@ const StylePanelRoot = styled(Panel.Root, {
minWidth: 1, minWidth: 1,
width: 184, width: 184,
maxWidth: 184, maxWidth: 184,
overflow: "hidden", overflow: 'hidden',
position: "relative", position: 'relative',
variants: { variants: {
isOpen: { isOpen: {
@ -129,3 +145,41 @@ const StylePanelRoot = styled(Panel.Root, {
const Content = styled(Panel.Content, { const Content = styled(Panel.Content, {
padding: 8, padding: 8,
}) })
const Row = styled('div', {
position: 'relative',
display: 'flex',
width: '100%',
background: 'none',
border: 'none',
cursor: 'pointer',
outline: 'none',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 2px 4px 12px',
'& label': {
fontFamily: '$ui',
fontSize: '$2',
fontWeight: '$1',
margin: 0,
padding: 0,
},
'& > svg': {
position: 'relative',
},
})
const ButtonsRow = styled('div', {
position: 'relative',
display: 'flex',
width: '100%',
background: 'none',
border: 'none',
cursor: 'pointer',
outline: 'none',
alignItems: 'center',
justifyContent: 'flex-start',
padding: 4,
})

View file

@ -0,0 +1,82 @@
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
import * as RadioGroup from '@radix-ui/react-radio-group'
import { IconButton } from 'components/shared'
import { ChangeEvent } from 'react'
import { Circle } from 'react-feather'
import state from 'state'
import styled from 'styles'
function setWidth(e: ChangeEvent<HTMLInputElement>) {
state.send('CHANGED_STYLE', {
strokeWidth: Number(e.currentTarget.value),
})
}
export default function WidthPicker({
strokeWidth = 2,
}: {
strokeWidth?: number
}) {
return (
<Group name="width" onValueChange={setWidth}>
<RadioItem value="2" isActive={strokeWidth === 2}>
<Circle size={6} />
</RadioItem>
<RadioItem value="4" isActive={strokeWidth === 4}>
<Circle size={12} />
</RadioItem>
<RadioItem value="8" isActive={strokeWidth === 8}>
<Circle size={18} />
</RadioItem>
</Group>
)
}
const Group = styled(RadioGroup.Root, {
display: 'flex',
})
const RadioItem = styled(RadioGroup.Item, {
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: '$hover',
'& svg': {
fill: '$text',
strokeWidth: '0',
},
},
'&:disabled': {
opacity: '0.5',
},
variants: {
isActive: {
true: {
'& svg': {
fill: '$text',
strokeWidth: '0',
},
},
false: {
'& svg': {
fill: '$inactive',
strokeWidth: '0',
},
},
},
},
})

View file

@ -1,134 +1,147 @@
import state, { useSelector } from "state" import state, { useSelector } from 'state'
import styled from "styles" import styled from 'styles'
import { Menu } from "react-feather" import { Lock, Menu, Unlock } from 'react-feather'
import { IconButton } from './shared'
export default function Toolbar() { export default function Toolbar() {
const activeTool = useSelector((state) => const activeTool = useSelector((state) =>
state.whenIn({ state.whenIn({
selecting: "select", selecting: 'select',
dot: "dot", dot: 'dot',
circle: "circle", circle: 'circle',
ellipse: "ellipse", ellipse: 'ellipse',
ray: "ray", ray: 'ray',
line: "line", line: 'line',
polyline: "polyline", polyline: 'polyline',
rectangle: "rectangle", rectangle: 'rectangle',
draw: "draw", draw: 'draw',
}) })
) )
const isToolLocked = useSelector((s) => s.data.settings.isToolLocked)
return ( return (
<ToolbarContainer> <ToolbarContainer>
<Section> <Section>
<Button> <Button>
<Menu /> <Menu />
</Button> </Button>
<Button onClick={() => state.send('TOGGLED_TOOL_LOCK')}>
{isToolLocked ? <Lock /> : <Unlock />}
</Button>
<Button <Button
isSelected={activeTool === "select"} isSelected={activeTool === 'select'}
onClick={() => state.send("SELECTED_SELECT_TOOL")} onClick={() => state.send('SELECTED_SELECT_TOOL')}
> >
Select Select
</Button> </Button>
<Button <Button
isSelected={activeTool === "draw"} isSelected={activeTool === 'draw'}
onClick={() => state.send("SELECTED_DRAW_TOOL")} onClick={() => state.send('SELECTED_DRAW_TOOL')}
> >
Draw Draw
</Button> </Button>
<Button <Button
isSelected={activeTool === "dot"} isSelected={activeTool === 'dot'}
onClick={() => state.send("SELECTED_DOT_TOOL")} onClick={() => state.send('SELECTED_DOT_TOOL')}
> >
Dot Dot
</Button> </Button>
<Button <Button
isSelected={activeTool === "circle"} isSelected={activeTool === 'circle'}
onClick={() => state.send("SELECTED_CIRCLE_TOOL")} onClick={() => state.send('SELECTED_CIRCLE_TOOL')}
> >
Circle Circle
</Button> </Button>
<Button <Button
isSelected={activeTool === "ellipse"} isSelected={activeTool === 'ellipse'}
onClick={() => state.send("SELECTED_ELLIPSE_TOOL")} onClick={() => state.send('SELECTED_ELLIPSE_TOOL')}
> >
Ellipse Ellipse
</Button> </Button>
<Button <Button
isSelected={activeTool === "ray"} isSelected={activeTool === 'ray'}
onClick={() => state.send("SELECTED_RAY_TOOL")} onClick={() => state.send('SELECTED_RAY_TOOL')}
> >
Ray Ray
</Button> </Button>
<Button <Button
isSelected={activeTool === "line"} isSelected={activeTool === 'line'}
onClick={() => state.send("SELECTED_LINE_TOOL")} onClick={() => state.send('SELECTED_LINE_TOOL')}
> >
Line Line
</Button> </Button>
<Button <Button
isSelected={activeTool === "polyline"} isSelected={activeTool === 'polyline'}
onClick={() => state.send("SELECTED_POLYLINE_TOOL")} onClick={() => state.send('SELECTED_POLYLINE_TOOL')}
> >
Polyline Polyline
</Button> </Button>
<Button <Button
isSelected={activeTool === "rectangle"} isSelected={activeTool === 'rectangle'}
onClick={() => state.send("SELECTED_RECTANGLE_TOOL")} onClick={() => state.send('SELECTED_RECTANGLE_TOOL')}
> >
Rectangle Rectangle
</Button> </Button>
<Button onClick={() => state.send("RESET_CAMERA")}>Reset Camera</Button> <Button onClick={() => state.send('RESET_CAMERA')}>Reset Camera</Button>
</Section> </Section>
<Section> <Section>
<Button onClick={() => state.send("UNDO")}>Undo</Button> <Button onClick={() => state.send('UNDO')}>Undo</Button>
<Button onClick={() => state.send("REDO")}>Redo</Button> <Button onClick={() => state.send('REDO')}>Redo</Button>
</Section> </Section>
</ToolbarContainer> </ToolbarContainer>
) )
} }
const ToolbarContainer = styled("div", { const ToolbarContainer = styled('div', {
gridArea: "toolbar", gridArea: 'toolbar',
userSelect: "none", userSelect: 'none',
borderBottom: "1px solid black", borderBottom: '1px solid black',
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "space-between", justifyContent: 'space-between',
backgroundColor: "$panel", backgroundColor: '$panel',
gap: 8, gap: 8,
fontSize: "$1", fontSize: '$1',
zIndex: 200, zIndex: 200,
}) })
const Section = styled("div", { const Section = styled('div', {
whiteSpace: "nowrap", whiteSpace: 'nowrap',
overflow: "hidden", overflowY: 'hidden',
display: "flex", overflowX: 'auto',
display: 'flex',
scrollbarWidth: 'none',
'&::-webkit-scrollbar': {
'-webkit-appearance': 'none',
width: 0,
height: 0,
},
}) })
const Button = styled("button", { const Button = styled('button', {
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
cursor: "pointer", cursor: 'pointer',
font: "$ui", font: '$ui',
fontSize: "$ui", fontSize: '$ui',
height: "40px", height: '40px',
outline: "none", outline: 'none',
borderRadius: 0, borderRadius: 0,
border: "none", border: 'none',
padding: "0 12px", padding: '0 12px',
background: "none", background: 'none',
"&:hover": { '&:hover': {
backgroundColor: "$hint", backgroundColor: '$hint',
}, },
"& svg": { '& svg': {
height: 16, height: 16,
width: 16, width: 16,
}, },
variants: { variants: {
isSelected: { isSelected: {
true: { true: {
color: "$selected", color: '$selected',
}, },
false: {}, false: {},
}, },

View file

@ -1,197 +1,217 @@
import { useEffect } from "react" import { useEffect } from 'react'
import state from "state" import state from 'state'
import { MoveType } from "types" import { MoveType } from 'types'
import { getKeyboardEventInfo, metaKey } from "utils/utils" import { getKeyboardEventInfo, metaKey } from 'utils/utils'
export default function useKeyboardEvents() { export default function useKeyboardEvents() {
useEffect(() => { useEffect(() => {
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
if (metaKey(e) && !["i", "r", "j"].includes(e.key)) { if (metaKey(e) && !['i', 'r', 'j'].includes(e.key)) {
e.preventDefault() e.preventDefault()
} }
switch (e.key) { switch (e.key) {
case "!": { case 'ArrowUp': {
state.send('NUDGED', { delta: [0, -1], ...getKeyboardEventInfo(e) })
break
}
case 'ArrowRight': {
state.send('NUDGED', { delta: [1, 0], ...getKeyboardEventInfo(e) })
break
}
case 'ArrowDown': {
state.send('NUDGED', { delta: [0, 1], ...getKeyboardEventInfo(e) })
break
}
case 'ArrowLeft': {
state.send('NUDGED', { delta: [-1, 0], ...getKeyboardEventInfo(e) })
break
}
case '!': {
// Shift + 1 // Shift + 1
if (e.shiftKey) { if (e.shiftKey) {
state.send("ZOOMED_TO_FIT") state.send('ZOOMED_TO_FIT')
} }
break break
} }
case "@": { case '@': {
// Shift + 2 // Shift + 2
if (e.shiftKey) { if (e.shiftKey) {
state.send("ZOOMED_TO_SELECTION") state.send('ZOOMED_TO_SELECTION')
} }
break break
} }
case ")": { case ')': {
// Shift + 0 // Shift + 0
if (e.shiftKey) { if (e.shiftKey) {
state.send("ZOOMED_TO_ACTUAL") state.send('ZOOMED_TO_ACTUAL')
} }
break break
} }
case "Escape": { case 'Escape': {
state.send("CANCELLED") state.send('CANCELLED')
break break
} }
case "z": { case 'z': {
if (metaKey(e)) { if (metaKey(e)) {
if (e.shiftKey) { if (e.shiftKey) {
state.send("REDO", getKeyboardEventInfo(e)) state.send('REDO', getKeyboardEventInfo(e))
} else { } else {
state.send("UNDO", getKeyboardEventInfo(e)) state.send('UNDO', getKeyboardEventInfo(e))
} }
} }
break break
} }
case "": { case '': {
if (metaKey(e)) { if (metaKey(e)) {
state.send("MOVED", { state.send('MOVED', {
...getKeyboardEventInfo(e), ...getKeyboardEventInfo(e),
type: MoveType.ToFront, type: MoveType.ToFront,
}) })
} }
break break
} }
case "“": { case '“': {
if (metaKey(e)) { if (metaKey(e)) {
state.send("MOVED", { state.send('MOVED', {
...getKeyboardEventInfo(e), ...getKeyboardEventInfo(e),
type: MoveType.ToBack, type: MoveType.ToBack,
}) })
} }
break break
} }
case "]": { case ']': {
if (metaKey(e)) { if (metaKey(e)) {
state.send("MOVED", { state.send('MOVED', {
...getKeyboardEventInfo(e), ...getKeyboardEventInfo(e),
type: MoveType.Forward, type: MoveType.Forward,
}) })
} }
break break
} }
case "[": { case '[': {
if (metaKey(e)) { if (metaKey(e)) {
state.send("MOVED", { state.send('MOVED', {
...getKeyboardEventInfo(e), ...getKeyboardEventInfo(e),
type: MoveType.Backward, type: MoveType.Backward,
}) })
} }
break break
} }
case "Shift": { case 'Shift': {
state.send("PRESSED_SHIFT_KEY", getKeyboardEventInfo(e)) state.send('PRESSED_SHIFT_KEY', getKeyboardEventInfo(e))
break break
} }
case "Alt": { case 'Alt': {
state.send("PRESSED_ALT_KEY", getKeyboardEventInfo(e)) state.send('PRESSED_ALT_KEY', getKeyboardEventInfo(e))
break break
} }
case "Backspace": { case 'Backspace': {
state.send("DELETED", getKeyboardEventInfo(e)) state.send('DELETED', getKeyboardEventInfo(e))
break break
} }
case "s": { case 's': {
if (metaKey(e)) { if (metaKey(e)) {
state.send("SAVED", getKeyboardEventInfo(e)) state.send('SAVED', getKeyboardEventInfo(e))
} }
break break
} }
case "a": { case 'a': {
if (metaKey(e)) { if (metaKey(e)) {
state.send("SELECTED_ALL", getKeyboardEventInfo(e)) state.send('SELECTED_ALL', getKeyboardEventInfo(e))
} }
break break
} }
case "v": { case 'v': {
if (metaKey(e)) { if (metaKey(e)) {
state.send("PASTED", getKeyboardEventInfo(e)) state.send('PASTED', getKeyboardEventInfo(e))
} else { } else {
state.send("SELECTED_SELECT_TOOL", getKeyboardEventInfo(e)) state.send('SELECTED_SELECT_TOOL', getKeyboardEventInfo(e))
} }
break break
} }
case "d": { case 'd': {
state.send("SELECTED_DRAW_TOOL", getKeyboardEventInfo(e))
break
}
case "t": {
if (metaKey(e)) { if (metaKey(e)) {
state.send("DUPLICATED", getKeyboardEventInfo(e)) state.send('DUPLICATED', getKeyboardEventInfo(e))
} else { } else {
state.send("SELECTED_DOT_TOOL", getKeyboardEventInfo(e)) state.send('SELECTED_DRAW_TOOL', getKeyboardEventInfo(e))
} }
break break
} }
case "c": { case 't': {
if (metaKey(e)) { if (metaKey(e)) {
state.send("COPIED", getKeyboardEventInfo(e)) state.send('DUPLICATED', getKeyboardEventInfo(e))
} else { } else {
state.send("SELECTED_CIRCLE_TOOL", getKeyboardEventInfo(e)) state.send('SELECTED_DOT_TOOL', getKeyboardEventInfo(e))
} }
break break
} }
case "i": { case 'c': {
if (metaKey(e)) {
state.send('COPIED', getKeyboardEventInfo(e))
} else {
state.send('SELECTED_CIRCLE_TOOL', getKeyboardEventInfo(e))
}
break
}
case 'i': {
if (metaKey(e)) { if (metaKey(e)) {
} else { } else {
state.send("SELECTED_ELLIPSE_TOOL", getKeyboardEventInfo(e)) state.send('SELECTED_ELLIPSE_TOOL', getKeyboardEventInfo(e))
} }
break break
} }
case "l": { case 'l': {
if (metaKey(e)) { if (metaKey(e)) {
} else { } else {
state.send("SELECTED_LINE_TOOL", getKeyboardEventInfo(e)) state.send('SELECTED_LINE_TOOL', getKeyboardEventInfo(e))
} }
break break
} }
case "y": { case 'y': {
if (metaKey(e)) { if (metaKey(e)) {
} else { } else {
state.send("SELECTED_RAY_TOOL", getKeyboardEventInfo(e)) state.send('SELECTED_RAY_TOOL', getKeyboardEventInfo(e))
} }
break break
} }
case "p": { case 'p': {
if (metaKey(e)) { if (metaKey(e)) {
} else { } else {
state.send("SELECTED_POLYLINE_TOOL", getKeyboardEventInfo(e)) state.send('SELECTED_POLYLINE_TOOL', getKeyboardEventInfo(e))
} }
break break
} }
case "r": { case 'r': {
if (metaKey(e)) { if (metaKey(e)) {
} else { } else {
state.send("SELECTED_RECTANGLE_TOOL", getKeyboardEventInfo(e)) state.send('SELECTED_RECTANGLE_TOOL', getKeyboardEventInfo(e))
} }
break break
} }
default: { default: {
state.send("PRESSED_KEY", getKeyboardEventInfo(e)) state.send('PRESSED_KEY', getKeyboardEventInfo(e))
} }
} }
} }
function handleKeyUp(e: KeyboardEvent) { function handleKeyUp(e: KeyboardEvent) {
if (e.key === "Shift") { if (e.key === 'Shift') {
state.send("RELEASED_SHIFT_KEY", getKeyboardEventInfo(e)) state.send('RELEASED_SHIFT_KEY', getKeyboardEventInfo(e))
} }
if (e.key === "Alt") { if (e.key === 'Alt') {
state.send("RELEASED_ALT_KEY", getKeyboardEventInfo(e)) state.send('RELEASED_ALT_KEY', getKeyboardEventInfo(e))
} }
state.send("RELEASED_KEY", getKeyboardEventInfo(e)) state.send('RELEASED_KEY', getKeyboardEventInfo(e))
} }
document.body.addEventListener("keydown", handleKeyDown) document.body.addEventListener('keydown', handleKeyDown)
document.body.addEventListener("keyup", handleKeyUp) document.body.addEventListener('keyup', handleKeyUp)
return () => { return () => {
document.body.removeEventListener("keydown", handleKeyDown) document.body.removeEventListener('keydown', handleKeyDown)
document.body.removeEventListener("keyup", handleKeyUp) document.body.removeEventListener('keyup', handleKeyUp)
} }
}, []) }, [])
} }

View file

@ -1,38 +1,38 @@
export const shades = { export const shades = {
transparent: "transparent", none: 'none',
white: "rgba(248, 249, 250, 1.000)", white: 'rgba(248, 249, 250, 1.000)',
lightGray: "rgba(224, 226, 230, 1.000)", lightGray: 'rgba(224, 226, 230, 1.000)',
gray: "rgba(172, 181, 189, 1.000)", gray: 'rgba(172, 181, 189, 1.000)',
darkGray: "rgba(52, 58, 64, 1.000)", darkGray: 'rgba(52, 58, 64, 1.000)',
black: "rgba(0,0,0, 1.000)", black: 'rgba(0,0,0, 1.000)',
} }
export const strokes = { export const strokes = {
lime: "rgba(115, 184, 23, 1.000)", lime: 'rgba(115, 184, 23, 1.000)',
green: "rgba(54, 178, 77, 1.000)", green: 'rgba(54, 178, 77, 1.000)',
teal: "rgba(9, 167, 120, 1.000)", teal: 'rgba(9, 167, 120, 1.000)',
cyan: "rgba(14, 152, 173, 1.000)", cyan: 'rgba(14, 152, 173, 1.000)',
blue: "rgba(28, 126, 214, 1.000)", blue: 'rgba(28, 126, 214, 1.000)',
indigo: "rgba(66, 99, 235, 1.000)", indigo: 'rgba(66, 99, 235, 1.000)',
violet: "rgba(112, 72, 232, 1.000)", violet: 'rgba(112, 72, 232, 1.000)',
grape: "rgba(174, 62, 200, 1.000)", grape: 'rgba(174, 62, 200, 1.000)',
pink: "rgba(214, 51, 108, 1.000)", pink: 'rgba(214, 51, 108, 1.000)',
red: "rgba(240, 63, 63, 1.000)", red: 'rgba(240, 63, 63, 1.000)',
orange: "rgba(247, 103, 6, 1.000)", orange: 'rgba(247, 103, 6, 1.000)',
yellow: "rgba(245, 159, 0, 1.000)", yellow: 'rgba(245, 159, 0, 1.000)',
} }
export const fills = { export const fills = {
lime: "rgba(217, 245, 162, 1.000)", lime: 'rgba(217, 245, 162, 1.000)',
green: "rgba(177, 242, 188, 1.000)", green: 'rgba(177, 242, 188, 1.000)',
teal: "rgba(149, 242, 215, 1.000)", teal: 'rgba(149, 242, 215, 1.000)',
cyan: "rgba(153, 233, 242, 1.000)", cyan: 'rgba(153, 233, 242, 1.000)',
blue: "rgba(166, 216, 255, 1.000)", blue: 'rgba(166, 216, 255, 1.000)',
indigo: "rgba(186, 200, 255, 1.000)", indigo: 'rgba(186, 200, 255, 1.000)',
violet: "rgba(208, 191, 255, 1.000)", violet: 'rgba(208, 191, 255, 1.000)',
grape: "rgba(237, 190, 250, 1.000)", grape: 'rgba(237, 190, 250, 1.000)',
pink: "rgba(252, 194, 215, 1.000)", pink: 'rgba(252, 194, 215, 1.000)',
red: "rgba(255, 201, 201, 1.000)", red: 'rgba(255, 201, 201, 1.000)',
orange: "rgba(255, 216, 168, 1.000)", orange: 'rgba(255, 216, 168, 1.000)',
yellow: "rgba(255, 236, 153, 1.000)", yellow: 'rgba(255, 236, 153, 1.000)',
} }

View file

@ -1,11 +1,11 @@
import { v4 as uuid } from "uuid" import { v4 as uuid } from 'uuid'
import * as vec from "utils/vec" import * as vec from 'utils/vec'
import { CircleShape, ShapeType } from "types" import { CircleShape, ShapeType } from 'types'
import { registerShapeUtils } from "./index" import { registerShapeUtils } 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" import { pointInCircle } from 'utils/hitTests'
import { translateBounds } from "utils/utils" import { translateBounds } from 'utils/utils'
const circle = registerShapeUtils<CircleShape>({ const circle = registerShapeUtils<CircleShape>({
boundsCache: new WeakMap([]), boundsCache: new WeakMap([]),
@ -15,22 +15,29 @@ const circle = registerShapeUtils<CircleShape>({
id: uuid(), id: uuid(),
type: ShapeType.Circle, type: ShapeType.Circle,
isGenerated: false, isGenerated: false,
name: "Circle", name: 'Circle',
parentId: "page0", parentId: 'page0',
childIndex: 0, childIndex: 0,
point: [0, 0], point: [0, 0],
rotation: 0, rotation: 0,
radius: 1, radius: 1,
style: { style: {
fill: "#c6cacb", fill: '#c6cacb',
stroke: "#000", stroke: '#000',
}, },
...props, ...props,
} }
}, },
render({ id, radius }) { render({ id, radius, style }) {
return <circle id={id} cx={radius} cy={radius} r={radius} /> return (
<circle
id={id}
cx={radius}
cy={radius}
r={Math.max(0, radius - Number(style.strokeWidth) / 2)}
/>
)
}, },
applyStyles(shape, style) { applyStyles(shape, style) {
@ -92,7 +99,7 @@ const circle = registerShapeUtils<CircleShape>({
}, },
translateTo(shape, point) { translateTo(shape, point) {
shape.point = point shape.point = vec.toPrecision(point)
return this return this
}, },

View file

@ -1,11 +1,11 @@
import { v4 as uuid } from "uuid" import { v4 as uuid } from 'uuid'
import * as vec from "utils/vec" import * as vec from 'utils/vec'
import { DotShape, ShapeType } from "types" import { DotShape, ShapeType } from 'types'
import { registerShapeUtils } from "./index" import { registerShapeUtils } from './index'
import { boundsContained } from "utils/bounds" import { boundsContained } from 'utils/bounds'
import { intersectCircleBounds } from "utils/intersections" import { intersectCircleBounds } from 'utils/intersections'
import { DotCircle } from "components/canvas/misc" import { DotCircle } from 'components/canvas/misc'
import { translateBounds } from "utils/utils" import { translateBounds } from 'utils/utils'
const dot = registerShapeUtils<DotShape>({ const dot = registerShapeUtils<DotShape>({
boundsCache: new WeakMap([]), boundsCache: new WeakMap([]),
@ -15,14 +15,14 @@ const dot = registerShapeUtils<DotShape>({
id: uuid(), id: uuid(),
type: ShapeType.Dot, type: ShapeType.Dot,
isGenerated: false, isGenerated: false,
name: "Dot", name: 'Dot',
parentId: "page0", parentId: 'page0',
childIndex: 0, childIndex: 0,
point: [0, 0], point: [0, 0],
rotation: 0, rotation: 0,
style: { style: {
fill: "#c6cacb", fill: '#c6cacb',
strokeWidth: "0", strokeWidth: '0',
}, },
...props, ...props,
} }
@ -79,7 +79,7 @@ const dot = registerShapeUtils<DotShape>({
}, },
translateTo(shape, point) { translateTo(shape, point) {
shape.point = point shape.point = vec.toPrecision(point)
return this return this
}, },

View file

@ -34,13 +34,13 @@ const draw = registerShapeUtils<DrawShape>({
strokeLinecap: 'round', strokeLinecap: 'round',
strokeLinejoin: 'round', strokeLinejoin: 'round',
...props.style, ...props.style,
stroke: 'transparent', fill: props.style.stroke,
}, },
} }
}, },
render(shape) { render(shape) {
const { id, point, points } = shape const { id, point, points, style } = shape
if (!pathCache.has(points)) { if (!pathCache.has(points)) {
if (points.length < 2) { if (points.length < 2) {
@ -51,7 +51,12 @@ const draw = registerShapeUtils<DrawShape>({
} }
pathCache.set(points, getSvgPathFromStroke(d)) pathCache.set(points, getSvgPathFromStroke(d))
} else { } else {
pathCache.set(points, getSvgPathFromStroke(getStroke(points))) pathCache.set(
points,
getSvgPathFromStroke(
getStroke(points, { size: Number(style.strokeWidth) * 2 })
)
)
} }
} }
@ -60,6 +65,7 @@ const draw = registerShapeUtils<DrawShape>({
applyStyles(shape, style) { applyStyles(shape, style) {
Object.assign(shape.style, style) Object.assign(shape.style, style)
shape.style.fill = shape.style.stroke
return this return this
}, },
@ -128,7 +134,7 @@ const draw = registerShapeUtils<DrawShape>({
}, },
translateTo(shape, point) { translateTo(shape, point) {
shape.point = point shape.point = vec.toPrecision(point)
return this return this
}, },

View file

@ -1,16 +1,16 @@
import { v4 as uuid } from "uuid" import { v4 as uuid } from 'uuid'
import * as vec from "utils/vec" import * as vec from 'utils/vec'
import { EllipseShape, ShapeType } from "types" import { EllipseShape, ShapeType } from 'types'
import { registerShapeUtils } from "./index" import { registerShapeUtils } from './index'
import { boundsContained, getRotatedEllipseBounds } from "utils/bounds" import { boundsContained, getRotatedEllipseBounds } from 'utils/bounds'
import { intersectEllipseBounds } from "utils/intersections" import { intersectEllipseBounds } from 'utils/intersections'
import { pointInEllipse } from "utils/hitTests" import { pointInEllipse } from 'utils/hitTests'
import { import {
getBoundsFromPoints, getBoundsFromPoints,
getRotatedCorners, getRotatedCorners,
rotateBounds, rotateBounds,
translateBounds, translateBounds,
} from "utils/utils" } from 'utils/utils'
const ellipse = registerShapeUtils<EllipseShape>({ const ellipse = registerShapeUtils<EllipseShape>({
boundsCache: new WeakMap([]), boundsCache: new WeakMap([]),
@ -20,24 +20,30 @@ const ellipse = registerShapeUtils<EllipseShape>({
id: uuid(), id: uuid(),
type: ShapeType.Ellipse, type: ShapeType.Ellipse,
isGenerated: false, isGenerated: false,
name: "Ellipse", name: 'Ellipse',
parentId: "page0", parentId: 'page0',
childIndex: 0, childIndex: 0,
point: [0, 0], point: [0, 0],
radiusX: 1, radiusX: 1,
radiusY: 1, radiusY: 1,
rotation: 0, rotation: 0,
style: { style: {
fill: "#c6cacb", fill: '#c6cacb',
stroke: "#000", stroke: '#000',
}, },
...props, ...props,
} }
}, },
render({ id, radiusX, radiusY }) { render({ id, radiusX, radiusY, style }) {
return ( return (
<ellipse id={id} cx={radiusX} cy={radiusY} rx={radiusX} ry={radiusY} /> <ellipse
id={id}
cx={radiusX}
cy={radiusY}
rx={Math.max(0, radiusX - Number(style.strokeWidth) / 2)}
ry={Math.max(0, radiusY - Number(style.strokeWidth) / 2)}
/>
) )
}, },
@ -110,7 +116,7 @@ const ellipse = registerShapeUtils<EllipseShape>({
}, },
translateTo(shape, point) { translateTo(shape, point) {
shape.point = point shape.point = vec.toPrecision(point)
return this return this
}, },

View file

@ -1,11 +1,11 @@
import { v4 as uuid } from "uuid" import { v4 as uuid } from 'uuid'
import * as vec from "utils/vec" import * as vec from 'utils/vec'
import { LineShape, ShapeType } from "types" import { LineShape, ShapeType } from 'types'
import { registerShapeUtils } from "./index" import { registerShapeUtils } from './index'
import { boundsContained } from "utils/bounds" import { boundsContained } from 'utils/bounds'
import { intersectCircleBounds } from "utils/intersections" import { intersectCircleBounds } from 'utils/intersections'
import { DotCircle } from "components/canvas/misc" import { DotCircle } from 'components/canvas/misc'
import { translateBounds } from "utils/utils" import { translateBounds } from 'utils/utils'
const line = registerShapeUtils<LineShape>({ const line = registerShapeUtils<LineShape>({
boundsCache: new WeakMap([]), boundsCache: new WeakMap([]),
@ -15,15 +15,15 @@ const line = registerShapeUtils<LineShape>({
id: uuid(), id: uuid(),
type: ShapeType.Line, type: ShapeType.Line,
isGenerated: false, isGenerated: false,
name: "Line", name: 'Line',
parentId: "page0", parentId: 'page0',
childIndex: 0, childIndex: 0,
point: [0, 0], point: [0, 0],
direction: [0, 0], direction: [0, 0],
rotation: 0, rotation: 0,
style: { style: {
fill: "#c6cacb", fill: '#c6cacb',
stroke: "#000", stroke: '#000',
}, },
...props, ...props,
} }
@ -88,7 +88,7 @@ const line = registerShapeUtils<LineShape>({
}, },
translateTo(shape, point) { translateTo(shape, point) {
shape.point = point shape.point = vec.toPrecision(point)
return this return this
}, },

View file

@ -1,10 +1,10 @@
import { v4 as uuid } from "uuid" import { v4 as uuid } from 'uuid'
import * as vec from "utils/vec" import * as vec from 'utils/vec'
import { PolylineShape, ShapeType } from "types" import { PolylineShape, ShapeType } from 'types'
import { registerShapeUtils } from "./index" import { registerShapeUtils } from './index'
import { intersectPolylineBounds } from "utils/intersections" import { intersectPolylineBounds } from 'utils/intersections'
import { boundsContainPolygon } from "utils/bounds" import { boundsContainPolygon } from 'utils/bounds'
import { getBoundsFromPoints, translateBounds } from "utils/utils" import { getBoundsFromPoints, translateBounds } from 'utils/utils'
const polyline = registerShapeUtils<PolylineShape>({ const polyline = registerShapeUtils<PolylineShape>({
boundsCache: new WeakMap([]), boundsCache: new WeakMap([]),
@ -14,16 +14,16 @@ const polyline = registerShapeUtils<PolylineShape>({
id: uuid(), id: uuid(),
type: ShapeType.Polyline, type: ShapeType.Polyline,
isGenerated: false, isGenerated: false,
name: "Polyline", name: 'Polyline',
parentId: "page0", parentId: 'page0',
childIndex: 0, childIndex: 0,
point: [0, 0], point: [0, 0],
points: [[0, 0]], points: [[0, 0]],
rotation: 0, rotation: 0,
style: { style: {
strokeWidth: 2, strokeWidth: 2,
strokeLinecap: "round", strokeLinecap: 'round',
strokeLinejoin: "round", strokeLinejoin: 'round',
}, },
...props, ...props,
} }
@ -97,7 +97,7 @@ const polyline = registerShapeUtils<PolylineShape>({
}, },
translateTo(shape, point) { translateTo(shape, point) {
shape.point = point shape.point = vec.toPrecision(point)
return this return this
}, },

View file

@ -1,11 +1,11 @@
import { v4 as uuid } from "uuid" import { v4 as uuid } from 'uuid'
import * as vec from "utils/vec" import * as vec from 'utils/vec'
import { RayShape, ShapeType } from "types" import { RayShape, ShapeType } from 'types'
import { registerShapeUtils } from "./index" import { registerShapeUtils } from './index'
import { boundsContained } from "utils/bounds" import { boundsContained } from 'utils/bounds'
import { intersectCircleBounds } from "utils/intersections" import { intersectCircleBounds } from 'utils/intersections'
import { DotCircle } from "components/canvas/misc" import { DotCircle } from 'components/canvas/misc'
import { translateBounds } from "utils/utils" import { translateBounds } from 'utils/utils'
const ray = registerShapeUtils<RayShape>({ const ray = registerShapeUtils<RayShape>({
boundsCache: new WeakMap([]), boundsCache: new WeakMap([]),
@ -15,15 +15,15 @@ const ray = registerShapeUtils<RayShape>({
id: uuid(), id: uuid(),
type: ShapeType.Ray, type: ShapeType.Ray,
isGenerated: false, isGenerated: false,
name: "Ray", name: 'Ray',
parentId: "page0", parentId: 'page0',
childIndex: 0, childIndex: 0,
point: [0, 0], point: [0, 0],
direction: [0, 1], direction: [0, 1],
rotation: 0, rotation: 0,
style: { style: {
fill: "#c6cacb", fill: '#c6cacb',
stroke: "#000", stroke: '#000',
strokeWidth: 1, strokeWidth: 1,
}, },
...props, ...props,
@ -88,7 +88,7 @@ const ray = registerShapeUtils<RayShape>({
}, },
translateTo(shape, point) { translateTo(shape, point) {
shape.point = point shape.point = vec.toPrecision(point)
return this return this
}, },

View file

@ -1,13 +1,13 @@
import { v4 as uuid } from "uuid" import { v4 as uuid } from 'uuid'
import * as vec from "utils/vec" import * as vec from 'utils/vec'
import { RectangleShape, ShapeType } from "types" import { RectangleShape, ShapeType } from 'types'
import { registerShapeUtils } from "./index" import { registerShapeUtils } from './index'
import { boundsCollidePolygon, boundsContainPolygon } from "utils/bounds" import { boundsCollidePolygon, boundsContainPolygon } from 'utils/bounds'
import { import {
getBoundsFromPoints, getBoundsFromPoints,
getRotatedCorners, getRotatedCorners,
translateBounds, translateBounds,
} from "utils/utils" } from 'utils/utils'
const rectangle = registerShapeUtils<RectangleShape>({ const rectangle = registerShapeUtils<RectangleShape>({
boundsCache: new WeakMap([]), boundsCache: new WeakMap([]),
@ -17,42 +17,31 @@ const rectangle = registerShapeUtils<RectangleShape>({
id: uuid(), id: uuid(),
type: ShapeType.Rectangle, type: ShapeType.Rectangle,
isGenerated: false, isGenerated: false,
name: "Rectangle", name: 'Rectangle',
parentId: "page0", parentId: 'page0',
childIndex: 0, childIndex: 0,
point: [0, 0], point: [0, 0],
size: [1, 1], size: [1, 1],
radius: 2, radius: 2,
rotation: 0, rotation: 0,
style: { style: {
fill: "#c6cacb", fill: '#c6cacb',
stroke: "#000", stroke: '#000',
}, },
...props, ...props,
} }
}, },
render({ id, size, radius, childIndex }) { render({ id, size, radius, style }) {
return ( return (
<g id={id}> <g id={id}>
<rect <rect
id={id} id={id}
width={size[0]}
height={size[1]}
rx={radius} rx={radius}
ry={radius} ry={radius}
width={Math.max(0, size[0] - Number(style.strokeWidth) / 2)}
height={Math.max(0, size[1] - Number(style.strokeWidth) / 2)}
/> />
<text
y={4}
x={4}
fontSize={18}
fill="black"
stroke="none"
alignmentBaseline="text-before-edge"
pointerEvents="none"
>
{childIndex}
</text>
</g> </g>
) )
}, },
@ -113,7 +102,7 @@ const rectangle = registerShapeUtils<RectangleShape>({
}, },
translateTo(shape, point) { translateTo(shape, point) {
shape.point = point shape.point = vec.toPrecision(point)
return this return this
}, },

View file

@ -11,6 +11,7 @@
"@monaco-editor/react": "^4.1.3", "@monaco-editor/react": "^4.1.3",
"@radix-ui/react-dropdown-menu": "^0.0.19", "@radix-ui/react-dropdown-menu": "^0.0.19",
"@radix-ui/react-icons": "^1.0.3", "@radix-ui/react-icons": "^1.0.3",
"@radix-ui/react-radio-group": "^0.0.16",
"@state-designer/react": "^1.7.1", "@state-designer/react": "^1.7.1",
"@stitches/react": "^0.1.9", "@stitches/react": "^0.1.9",
"framer-motion": "^4.1.16", "framer-motion": "^4.1.16",

View file

@ -0,0 +1,48 @@
import Command from './command'
import history from '../history'
import { Data } from 'types'
import { getPage, getSelectedShapes } from 'utils/utils'
import { v4 as uuid } from 'uuid'
import { current } from 'immer'
import * as vec from 'utils/vec'
export default function duplicateCommand(data: Data) {
const { currentPageId } = data
const selectedShapes = getSelectedShapes(current(data))
const duplicates = selectedShapes.map((shape) => ({
...shape,
id: uuid(),
point: vec.add(shape.point, vec.div([16, 16], data.camera.zoom)),
}))
history.execute(
data,
new Command({
name: 'duplicate_shapes',
category: 'canvas',
manualSelection: true,
do(data) {
const { shapes } = getPage(data, currentPageId)
data.selectedIds.clear()
for (const duplicate of duplicates) {
shapes[duplicate.id] = duplicate
data.selectedIds.add(duplicate.id)
}
},
undo(data) {
const { shapes } = getPage(data, currentPageId)
data.selectedIds.clear()
for (const duplicate of duplicates) {
delete shapes[duplicate.id]
}
for (let id in selectedShapes) {
data.selectedIds.add(id)
}
},
})
)
}

View file

@ -1,22 +1,25 @@
import align from "./align" import align from './align'
import deleteSelected from "./delete-selected" import deleteSelected from './delete-selected'
import direct from "./direct" import direct from './direct'
import distribute from "./distribute" import distribute from './distribute'
import generate from "./generate" import duplicate from './duplicate'
import move from "./move" import generate from './generate'
import draw from "./draw" import move from './move'
import rotate from "./rotate" import draw from './draw'
import stretch from "./stretch" import rotate from './rotate'
import style from "./style" import stretch from './stretch'
import transform from "./transform" import style from './style'
import transformSingle from "./transform-single" import transform from './transform'
import translate from "./translate" import transformSingle from './transform-single'
import translate from './translate'
import nudge from './nudge'
const commands = { const commands = {
align, align,
deleteSelected, deleteSelected,
direct, direct,
distribute, distribute,
duplicate,
generate, generate,
move, move,
draw, draw,
@ -26,6 +29,7 @@ const commands = {
transform, transform,
transformSingle, transformSingle,
translate, translate,
nudge,
} }
export default commands export default commands

40
state/commands/nudge.ts Normal file
View file

@ -0,0 +1,40 @@
import Command from './command'
import history from '../history'
import { Data } from 'types'
import { getPage, getSelectedShapes } from 'utils/utils'
import { getShapeUtils } from 'lib/shape-utils'
import * as vec from 'utils/vec'
export default function nudgeCommand(data: Data, delta: number[]) {
const { currentPageId } = data
const selectedShapes = getSelectedShapes(data)
const shapeBounds = Object.fromEntries(
selectedShapes.map(
(shape) => [shape.id, getShapeUtils(shape).getBounds(shape)] as const
)
)
history.execute(
data,
new Command({
name: 'set_direction',
category: 'canvas',
do(data) {
const { shapes } = getPage(data, currentPageId)
for (let id in shapeBounds) {
const shape = shapes[id]
getShapeUtils(shape).translateTo(shape, vec.add(shape.point, delta))
}
},
undo(data) {
const { shapes } = getPage(data, currentPageId)
for (let id in shapeBounds) {
const shape = shapes[id]
getShapeUtils(shape).translateTo(shape, vec.sub(shape.point, delta))
}
},
})
)
}

View file

@ -1,10 +1,10 @@
import Command from "./command" import Command from './command'
import history from "../history" import history from '../history'
import { Data, Corner, Edge } from "types" import { Data, Corner, Edge } from 'types'
import { getShapeUtils } from "lib/shape-utils" import { getShapeUtils } from 'lib/shape-utils'
import { current } from "immer" import { current } from 'immer'
import { TransformSingleSnapshot } from "state/sessions/transform-single-session" import { TransformSingleSnapshot } from 'state/sessions/transform-single-session'
import { getPage } from "utils/utils" import { getPage } from 'utils/utils'
export default function transformSingleCommand( export default function transformSingleCommand(
data: Data, data: Data,
@ -14,13 +14,13 @@ export default function transformSingleCommand(
scaleY: number, scaleY: number,
isCreating: boolean isCreating: boolean
) { ) {
const shape = getPage(data, after.currentPageId).shapes[after.id] const shape = current(getPage(data, after.currentPageId).shapes[after.id])
history.execute( history.execute(
data, data,
new Command({ new Command({
name: "transform_single_shape", name: 'transform_single_shape',
category: "canvas", category: 'canvas',
manualSelection: true, manualSelection: true,
do(data) { do(data) {
const { id, type, initialShape, initialShapeBounds } = after const { id, type, initialShape, initialShapeBounds } = after

View file

@ -41,10 +41,15 @@ const initialData: Data = {
isDarkMode: false, isDarkMode: false,
isCodeOpen: false, isCodeOpen: false,
isStyleOpen: false, isStyleOpen: false,
isToolLocked: false,
isPenLocked: false,
nudgeDistanceLarge: 10,
nudgeDistanceSmall: 1,
}, },
currentStyle: { currentStyle: {
fill: shades.lightGray, fill: shades.lightGray,
stroke: shades.darkGray, stroke: shades.darkGray,
strokeWidth: 2,
}, },
camera: { camera: {
point: [0, 0], point: [0, 0],
@ -94,6 +99,9 @@ const state = createState({
else: 'zoomCameraToActual', else: 'zoomCameraToActual',
}, },
SELECTED_ALL: { to: 'selecting', do: 'selectAll' }, SELECTED_ALL: { to: 'selecting', do: 'selectAll' },
NUDGED: { do: 'nudgeSelection' },
USED_PEN_DEVICE: 'enablePenLock',
DISABLED_PEN_LOCK: 'disablePenLock',
}, },
initial: 'loading', initial: 'loading',
states: { states: {
@ -124,20 +132,22 @@ const state = createState({
selecting: { selecting: {
on: { on: {
SAVED: 'forceSave', SAVED: 'forceSave',
UNDO: { do: 'undo' }, UNDO: 'undo',
REDO: { do: 'redo' }, REDO: 'redo',
CANCELLED: { do: 'clearSelectedIds' },
DELETED: { do: 'deleteSelectedIds' },
SAVED_CODE: 'saveCode', SAVED_CODE: 'saveCode',
GENERATED_FROM_CODE: ['setCodeControls', 'setGeneratedShapes'], CANCELLED: 'clearSelectedIds',
DELETED: 'deleteSelectedIds',
STARTED_PINCHING: { to: 'pinching' },
INCREASED_CODE_FONT_SIZE: 'increaseCodeFontSize', INCREASED_CODE_FONT_SIZE: 'increaseCodeFontSize',
DECREASED_CODE_FONT_SIZE: 'decreaseCodeFontSize', DECREASED_CODE_FONT_SIZE: 'decreaseCodeFontSize',
CHANGED_CODE_CONTROL: 'updateControls', CHANGED_CODE_CONTROL: 'updateControls',
ALIGNED: 'alignSelection', GENERATED_FROM_CODE: ['setCodeControls', 'setGeneratedShapes'],
STRETCHED: 'stretchSelection', TOGGLED_TOOL_LOCK: 'toggleToolLock',
DISTRIBUTED: 'distributeSelection', MOVED: { if: 'hasSelection', do: 'moveSelection' },
MOVED: 'moveSelection', ALIGNED: { if: 'hasSelection', do: 'alignSelection' },
STARTED_PINCHING: { to: 'pinching' }, STRETCHED: { if: 'hasSelection', do: 'stretchSelection' },
DISTRIBUTED: { if: 'hasSelection', do: 'distributeSelection' },
DUPLICATED: { if: 'hasSelection', do: 'duplicateSelection' },
}, },
initial: 'notPointing', initial: 'notPointing',
states: { states: {
@ -262,239 +272,257 @@ const state = createState({
PINCHED: { do: 'pinchCamera' }, PINCHED: { do: 'pinchCamera' },
}, },
}, },
draw: { usingTool: {
initial: 'creating', initial: 'draw',
states: { states: {
creating: { draw: {
on: { initial: 'creating',
CANCELLED: { to: 'selecting' },
POINTED_CANVAS: {
get: 'newDraw',
do: 'createShape',
to: 'draw.editing',
},
UNDO: { do: 'undo' },
REDO: { do: 'redo' },
},
},
editing: {
onEnter: 'startDrawSession',
on: {
STOPPED_POINTING: {
do: 'completeSession',
to: 'draw.creating',
},
CANCELLED: {
do: ['cancelSession', 'deleteSelectedIds'],
to: 'selecting',
},
MOVED_POINTER: 'updateDrawSession',
PANNED_CAMERA: 'updateDrawSession',
},
},
},
},
dot: {
initial: 'creating',
states: {
creating: {
on: {
CANCELLED: { to: 'selecting' },
POINTED_CANVAS: {
get: 'newDot',
do: 'createShape',
to: 'dot.editing',
},
},
},
editing: {
on: {
STOPPED_POINTING: { do: 'completeSession', to: 'selecting' },
CANCELLED: {
do: ['cancelSession', 'deleteSelectedIds'],
to: 'selecting',
},
},
initial: 'inactive',
states: { states: {
inactive: { creating: {
on: { on: {
MOVED_POINTER: { CANCELLED: { to: 'selecting' },
if: 'distanceImpliesDrag', POINTED_CANVAS: {
to: 'dot.editing.active', get: 'newDraw',
do: 'createShape',
to: 'draw.editing',
},
UNDO: { do: 'undo' },
REDO: { do: 'redo' },
},
},
editing: {
onEnter: 'startDrawSession',
on: {
STOPPED_POINTING: {
do: 'completeSession',
to: 'draw.creating',
},
CANCELLED: {
do: ['cancelSession', 'deleteSelectedIds'],
to: 'selecting',
},
MOVED_POINTER: 'updateDrawSession',
PANNED_CAMERA: 'updateDrawSession',
},
},
},
},
dot: {
initial: 'creating',
states: {
creating: {
on: {
CANCELLED: { to: 'selecting' },
POINTED_CANVAS: {
get: 'newDot',
do: 'createShape',
to: 'dot.editing',
}, },
}, },
}, },
active: { editing: {
onEnter: 'startTranslateSession',
on: { on: {
MOVED_POINTER: 'updateTranslateSession', STOPPED_POINTING: [
PANNED_CAMERA: 'updateTranslateSession', 'completeSession',
{
if: 'isToolLocked',
to: 'dot.creating',
else: {
to: 'selecting',
},
},
],
CANCELLED: {
do: ['cancelSession', 'deleteSelectedIds'],
to: 'selecting',
},
},
initial: 'inactive',
states: {
inactive: {
on: {
MOVED_POINTER: {
if: 'distanceImpliesDrag',
to: 'dot.editing.active',
},
},
},
active: {
onEnter: 'startTranslateSession',
on: {
MOVED_POINTER: 'updateTranslateSession',
PANNED_CAMERA: 'updateTranslateSession',
},
},
}, },
}, },
}, },
}, },
}, circle: {
}, initial: 'creating',
circle: { states: {
initial: 'creating', creating: {
states: { on: {
creating: { CANCELLED: { to: 'selecting' },
on: { POINTED_CANVAS: {
CANCELLED: { to: 'selecting' }, to: 'circle.editing',
POINTED_CANVAS: { },
to: 'circle.editing', },
}, },
}, editing: {
}, on: {
editing: { STOPPED_POINTING: { to: 'selecting' },
on: { CANCELLED: { to: 'selecting' },
STOPPED_POINTING: { to: 'selecting' }, MOVED_POINTER: {
CANCELLED: { to: 'selecting' }, if: 'distanceImpliesDrag',
MOVED_POINTER: { then: {
if: 'distanceImpliesDrag', get: 'newCircle',
then: { do: 'createShape',
get: 'newCircle', to: 'drawingShape.bounds',
do: 'createShape', },
to: 'drawingShape.bounds', },
}, },
}, },
}, },
}, },
}, ellipse: {
}, initial: 'creating',
ellipse: { states: {
initial: 'creating', creating: {
states: { on: {
creating: { CANCELLED: { to: 'selecting' },
on: { POINTED_CANVAS: {
CANCELLED: { to: 'selecting' }, to: 'ellipse.editing',
POINTED_CANVAS: { },
to: 'ellipse.editing', },
}, },
}, editing: {
}, on: {
editing: { STOPPED_POINTING: { to: 'selecting' },
on: { CANCELLED: { to: 'selecting' },
STOPPED_POINTING: { to: 'selecting' }, MOVED_POINTER: {
CANCELLED: { to: 'selecting' }, if: 'distanceImpliesDrag',
MOVED_POINTER: { then: {
if: 'distanceImpliesDrag', get: 'newEllipse',
then: { do: 'createShape',
get: 'newEllipse', to: 'drawingShape.bounds',
do: 'createShape', },
to: 'drawingShape.bounds', },
}, },
}, },
}, },
}, },
}, rectangle: {
}, initial: 'creating',
rectangle: { states: {
initial: 'creating', creating: {
states: { on: {
creating: { CANCELLED: { to: 'selecting' },
on: { POINTED_CANVAS: {
CANCELLED: { to: 'selecting' }, to: 'rectangle.editing',
POINTED_CANVAS: { },
to: 'rectangle.editing', },
}, },
}, editing: {
}, on: {
editing: { STOPPED_POINTING: { to: 'selecting' },
on: { CANCELLED: { to: 'selecting' },
STOPPED_POINTING: { to: 'selecting' }, MOVED_POINTER: {
CANCELLED: { to: 'selecting' }, if: 'distanceImpliesDrag',
MOVED_POINTER: { then: {
if: 'distanceImpliesDrag', get: 'newRectangle',
then: { do: 'createShape',
get: 'newRectangle', to: 'drawingShape.bounds',
do: 'createShape', },
to: 'drawingShape.bounds', },
}, },
}, },
}, },
}, },
ray: {
initial: 'creating',
states: {
creating: {
on: {
CANCELLED: { to: 'selecting' },
POINTED_CANVAS: {
get: 'newRay',
do: 'createShape',
to: 'ray.editing',
},
},
},
editing: {
on: {
STOPPED_POINTING: { to: 'selecting' },
CANCELLED: { to: 'selecting' },
MOVED_POINTER: {
if: 'distanceImpliesDrag',
to: 'drawingShape.direction',
},
},
},
},
},
line: {
initial: 'creating',
states: {
creating: {
on: {
CANCELLED: { to: 'selecting' },
POINTED_CANVAS: {
get: 'newLine',
do: 'createShape',
to: 'line.editing',
},
},
},
editing: {
on: {
STOPPED_POINTING: { to: 'selecting' },
CANCELLED: { to: 'selecting' },
MOVED_POINTER: {
if: 'distanceImpliesDrag',
to: 'drawingShape.direction',
},
},
},
},
},
polyline: {},
}, },
}, },
ray: { drawingShape: {
initial: 'creating',
states: {
creating: {
on: {
CANCELLED: { to: 'selecting' },
POINTED_CANVAS: {
get: 'newRay',
do: 'createShape',
to: 'ray.editing',
},
},
},
editing: {
on: {
STOPPED_POINTING: { to: 'selecting' },
CANCELLED: { to: 'selecting' },
MOVED_POINTER: {
if: 'distanceImpliesDrag',
to: 'drawingShape.direction',
},
},
},
},
},
line: {
initial: 'creating',
states: {
creating: {
on: {
CANCELLED: { to: 'selecting' },
POINTED_CANVAS: {
get: 'newLine',
do: 'createShape',
to: 'line.editing',
},
},
},
editing: {
on: {
STOPPED_POINTING: { to: 'selecting' },
CANCELLED: { to: 'selecting' },
MOVED_POINTER: {
if: 'distanceImpliesDrag',
to: 'drawingShape.direction',
},
},
},
},
},
polyline: {},
},
},
drawingShape: {
on: {
STOPPED_POINTING: {
do: 'completeSession',
to: 'selecting',
},
CANCELLED: {
do: ['cancelSession', 'deleteSelectedIds'],
to: 'selecting',
},
},
initial: 'drawingShapeBounds',
states: {
bounds: {
onEnter: 'startDrawTransformSession',
on: { on: {
MOVED_POINTER: 'updateTransformSession', STOPPED_POINTING: [
PANNED_CAMERA: 'updateTransformSession', 'completeSession',
{
if: 'isToolLocked',
to: 'usingTool.previous',
else: { to: 'selecting' },
},
],
CANCELLED: {
do: ['cancelSession', 'deleteSelectedIds'],
to: 'selecting',
},
}, },
}, initial: 'drawingShapeBounds',
direction: { states: {
onEnter: 'startDirectionSession', bounds: {
on: { onEnter: 'startDrawTransformSession',
MOVED_POINTER: 'updateDirectionSession', on: {
PANNED_CAMERA: 'updateDirectionSession', MOVED_POINTER: 'updateTransformSession',
PANNED_CAMERA: 'updateTransformSession',
},
},
direction: {
onEnter: 'startDirectionSession',
on: {
MOVED_POINTER: 'updateDirectionSession',
PANNED_CAMERA: 'updateDirectionSession',
},
},
}, },
}, },
}, },
@ -562,6 +590,12 @@ const state = createState({
hasSelection(data) { hasSelection(data) {
return data.selectedIds.size > 0 return data.selectedIds.size > 0
}, },
isToolLocked(data) {
return data.settings.isToolLocked
},
isPenLocked(data) {
return data.settings.isPenLocked
},
}, },
actions: { actions: {
/* --------------------- Shapes --------------------- */ /* --------------------- Shapes --------------------- */
@ -712,6 +746,19 @@ const state = createState({
session.update(data, screenToWorld(payload.point, data)) session.update(data, screenToWorld(payload.point, data))
}, },
// Nudges
nudgeSelection(data, payload: { delta: number[]; shiftKey: boolean }) {
commands.nudge(
data,
vec.mul(
payload.delta,
payload.shiftKey
? data.settings.nudgeDistanceLarge
: data.settings.nudgeDistanceSmall
)
)
},
/* -------------------- Selection ------------------- */ /* -------------------- Selection ------------------- */
selectAll(data) { selectAll(data) {
@ -756,6 +803,9 @@ const state = createState({
distributeSelection(data, payload: { type: DistributeType }) { distributeSelection(data, payload: { type: DistributeType }) {
commands.distribute(data, payload.type) commands.distribute(data, payload.type)
}, },
duplicateSelection(data) {
commands.duplicate(data)
},
/* --------------------- Camera --------------------- */ /* --------------------- Camera --------------------- */
@ -913,6 +963,7 @@ const state = createState({
}, },
/* ---------------------- Code ---------------------- */ /* ---------------------- Code ---------------------- */
closeCodePanel(data) { closeCodePanel(data) {
data.settings.isCodeOpen = false data.settings.isCodeOpen = false
}, },
@ -962,7 +1013,20 @@ const state = createState({
history.enable() history.enable()
}, },
// Data /* -------------------- Settings -------------------- */
enablePenLock(data) {
data.settings.isPenLocked = true
},
disablePenLock(data) {
data.settings.isPenLocked = false
},
toggleToolLock(data) {
data.settings.isToolLocked = !data.settings.isToolLocked
},
/* ---------------------- Data ---------------------- */
saveCode(data, payload: { code: string }) { saveCode(data, payload: { code: string }) {
data.document.code[data.currentCodeFileId].code = payload.code data.document.code[data.currentCodeFileId].code = payload.code
history.save(data) history.save(data)

View file

@ -1,4 +1,4 @@
import { createCss, defaultThemeMap } from "@stitches/react" import { createCss, defaultThemeMap } from '@stitches/react'
const { styled, global, css, theme, getCssString } = createCss({ const { styled, global, css, theme, getCssString } = createCss({
themeMap: { themeMap: {
@ -6,26 +6,27 @@ const { styled, global, css, theme, getCssString } = createCss({
}, },
theme: { theme: {
colors: { colors: {
brushFill: "rgba(0,0,0,.1)", brushFill: 'rgba(0,0,0,.1)',
brushStroke: "rgba(0,0,0,.5)", brushStroke: 'rgba(0,0,0,.5)',
hint: "rgba(66, 133, 244, 0.200)", hint: 'rgba(66, 133, 244, 0.200)',
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", border: '#aaa',
panel: "#fefefe", panel: '#fefefe',
hover: "#efefef", inactive: '#cccccf',
text: "#333", hover: '#efefef',
input: "#f3f3f3", text: '#333',
inputBorder: "#ddd", input: '#f3f3f3',
inputBorder: '#ddd',
}, },
space: {}, space: {},
fontSizes: { fontSizes: {
0: "10px", 0: '10px',
1: "12px", 1: '12px',
2: "13px", 2: '13px',
3: "16px", 3: '16px',
4: "18px", 4: '18px',
}, },
fonts: { fonts: {
ui: '"Recursive", system-ui, sans-serif', ui: '"Recursive", system-ui, sans-serif',
@ -72,17 +73,17 @@ const light = theme({})
const dark = theme({}) const dark = theme({})
const globalStyles = global({ const globalStyles = global({
"*": { boxSizing: "border-box" }, '*': { boxSizing: 'border-box' },
":root": { ':root': {
"--camera-zoom": 1, '--camera-zoom': 1,
"--scale": "calc(1 / var(--camera-zoom))", '--scale': 'calc(1 / var(--camera-zoom))',
}, },
"html, body": { 'html, body': {
padding: "0px", padding: '0px',
margin: "0px", margin: '0px',
overscrollBehavior: "none", overscrollBehavior: 'none',
fontFamily: "$ui", fontFamily: '$ui',
fontSize: "$2", fontSize: '$2',
}, },
}) })

View file

@ -1,6 +1,6 @@
import * as monaco from "monaco-editor/esm/vs/editor/editor.api" import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'
import React from "react" import React from 'react'
/* -------------------------------------------------- */ /* -------------------------------------------------- */
/* Client State */ /* Client State */
@ -13,6 +13,10 @@ export interface Data {
isDarkMode: boolean isDarkMode: boolean
isCodeOpen: boolean isCodeOpen: boolean
isStyleOpen: boolean isStyleOpen: boolean
nudgeDistanceSmall: number
nudgeDistanceLarge: number
isToolLocked: boolean
isPenLocked: boolean
} }
currentStyle: ShapeStyles currentStyle: ShapeStyles
camera: { camera: {
@ -39,21 +43,21 @@ export interface Data {
export interface Page { export interface Page {
id: string id: string
type: "page" type: 'page'
childIndex: number childIndex: number
name: string name: string
shapes: Record<string, Shape> shapes: Record<string, Shape>
} }
export enum ShapeType { export enum ShapeType {
Dot = "dot", Dot = 'dot',
Circle = "circle", Circle = 'circle',
Ellipse = "ellipse", Ellipse = 'ellipse',
Line = "line", Line = 'line',
Ray = "ray", Ray = 'ray',
Polyline = "polyline", Polyline = 'polyline',
Rectangle = "rectangle", Rectangle = 'rectangle',
Draw = "draw", Draw = 'draw',
} }
// Consider: // Consider:
@ -164,17 +168,17 @@ export interface PointerInfo {
} }
export enum Edge { export enum Edge {
Top = "top_edge", Top = 'top_edge',
Right = "right_edge", Right = 'right_edge',
Bottom = "bottom_edge", Bottom = 'bottom_edge',
Left = "left_edge", Left = 'left_edge',
} }
export enum Corner { export enum Corner {
TopLeft = "top_left_corner", TopLeft = 'top_left_corner',
TopRight = "top_right_corner", TopRight = 'top_right_corner',
BottomRight = "bottom_right_corner", BottomRight = 'bottom_right_corner',
BottomLeft = "bottom_left_corner", BottomLeft = 'bottom_left_corner',
} }
export interface Bounds { export interface Bounds {
@ -262,10 +266,10 @@ export type IMonaco = typeof monaco
export type IMonacoEditor = monaco.editor.IStandaloneCodeEditor export type IMonacoEditor = monaco.editor.IStandaloneCodeEditor
export enum ControlType { export enum ControlType {
Number = "number", Number = 'number',
Vector = "vector", Vector = 'vector',
Text = "text", Text = 'text',
Select = "select", Select = 'select',
} }
export interface BaseCodeControl { export interface BaseCodeControl {
@ -296,7 +300,7 @@ export interface TextCodeControl extends BaseCodeControl {
format?: (value: string) => string format?: (value: string) => string
} }
export interface SelectCodeControl<T extends string = ""> export interface SelectCodeControl<T extends string = ''>
extends BaseCodeControl { extends BaseCodeControl {
type: ControlType.Select type: ControlType.Select
value: T value: T

View file

@ -1,9 +1,9 @@
import Vector from "lib/code/vector" import Vector from 'lib/code/vector'
import React from "react" import React from 'react'
import { Data, Bounds, Edge, Corner, Shape, ShapeStyles } from "types" import { Data, Bounds, Edge, Corner, Shape, ShapeStyles } from 'types'
import * as vec from "./vec" import * as vec from './vec'
import _isMobile from "ismobilejs" import _isMobile from 'ismobilejs'
import { getShapeUtils } from "lib/shape-utils" import { getShapeUtils } from 'lib/shape-utils'
export function screenToWorld(point: number[], data: Data) { export function screenToWorld(point: number[], data: Data) {
return vec.sub(vec.div(point, data.camera.zoom), data.camera.point) return vec.sub(vec.div(point, data.camera.zoom), data.camera.point)
@ -132,7 +132,7 @@ export function getBezierCurveSegments(points: number[][], tension = 0.4) {
cpoints: number[][] = [...points] cpoints: number[][] = [...points]
if (len < 2) { if (len < 2) {
throw Error("Curve must have at least two points.") throw Error('Curve must have at least two points.')
} }
for (let i = 1; i < len - 1; i++) { for (let i = 1; i < len - 1; i++) {
@ -260,12 +260,12 @@ export function copyToClipboard(string: string) {
navigator.clipboard.writeText(string) navigator.clipboard.writeText(string)
} catch (e) { } catch (e) {
try { try {
textarea = document.createElement("textarea") textarea = document.createElement('textarea')
textarea.setAttribute("position", "fixed") textarea.setAttribute('position', 'fixed')
textarea.setAttribute("top", "0") textarea.setAttribute('top', '0')
textarea.setAttribute("readonly", "true") textarea.setAttribute('readonly', 'true')
textarea.setAttribute("contenteditable", "true") textarea.setAttribute('contenteditable', 'true')
textarea.style.position = "fixed" // prevent scroll from jumping to the bottom when focus is set. textarea.style.position = 'fixed' // prevent scroll from jumping to the bottom when focus is set.
textarea.value = string textarea.value = string
document.body.appendChild(textarea) document.body.appendChild(textarea)
@ -281,7 +281,7 @@ export function copyToClipboard(string: string) {
sel.addRange(range) sel.addRange(range)
textarea.setSelectionRange(0, textarea.value.length) textarea.setSelectionRange(0, textarea.value.length)
result = document.execCommand("copy") result = document.execCommand('copy')
} catch (err) { } catch (err) {
result = null result = null
} finally { } finally {
@ -549,7 +549,7 @@ export function arrsIntersect<T>(
export function getTouchDisplay() { export function getTouchDisplay() {
return ( return (
"ontouchstart" in window || 'ontouchstart' in window ||
navigator.maxTouchPoints > 0 || navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0 navigator.msMaxTouchPoints > 0
) )
@ -604,7 +604,7 @@ export function modulate(
export function clamp(n: number, min: number): number export function clamp(n: number, min: number): number
export function clamp(n: number, min: number, max: number): number export function clamp(n: number, min: number, max: number): number
export function clamp(n: number, min: number, max?: number): number { export function clamp(n: number, min: number, max?: number): number {
return Math.max(min, typeof max !== "undefined" ? Math.min(n, max) : n) return Math.max(min, typeof max !== 'undefined' ? Math.min(n, max) : n)
} }
// CURVES // CURVES
@ -871,8 +871,8 @@ export async function postJsonToEndpoint(
const d = await fetch( const d = await fetch(
`${process.env.NEXT_PUBLIC_BASE_API_URL}/api/${endpoint}`, `${process.env.NEXT_PUBLIC_BASE_API_URL}/api/${endpoint}`,
{ {
method: "POST", method: 'POST',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
} }
) )
@ -962,7 +962,7 @@ export function getTransformAnchor(
} }
export function vectorToPoint(point: number[] | Vector | undefined) { export function vectorToPoint(point: number[] | Vector | undefined) {
if (typeof point === "undefined") { if (typeof point === 'undefined') {
return [0, 0] return [0, 0]
} }
@ -1062,7 +1062,7 @@ export function getRotatedCorners(b: Bounds, rotation: number) {
export function getTransformedBoundingBox( export function getTransformedBoundingBox(
bounds: Bounds, bounds: Bounds,
handle: Corner | Edge | "center", handle: Corner | Edge | 'center',
delta: number[], delta: number[],
rotation = 0, rotation = 0,
isAspectRatioLocked = false isAspectRatioLocked = false
@ -1076,7 +1076,7 @@ export function getTransformedBoundingBox(
let [bx1, by1] = [bounds.maxX, bounds.maxY] let [bx1, by1] = [bounds.maxX, bounds.maxY]
// If the drag is on the center, just translate the bounds. // If the drag is on the center, just translate the bounds.
if (handle === "center") { if (handle === 'center') {
return { return {
minX: bx0 + delta[0], minX: bx0 + delta[0],
minY: by0 + delta[1], minY: by0 + delta[1],
@ -1491,7 +1491,7 @@ export function forceIntegerChildIndices(shapes: Shape[]) {
} }
} }
export function setZoomCSS(zoom: number) { export function setZoomCSS(zoom: number) {
document.documentElement.style.setProperty("--camera-zoom", zoom.toString()) document.documentElement.style.setProperty('--camera-zoom', zoom.toString())
} }
export function getCurrent<T extends object>(source: T): T { export function getCurrent<T extends object>(source: T): T {
@ -1539,7 +1539,7 @@ export function simplify(points: number[][], tolerance = 1) {
} }
export function getSvgPathFromStroke(stroke: number[][]) { export function getSvgPathFromStroke(stroke: number[][]) {
if (!stroke.length) return "" if (!stroke.length) return ''
const d = stroke.reduce( const d = stroke.reduce(
(acc, [x0, y0], i, arr) => { (acc, [x0, y0], i, arr) => {
@ -1547,9 +1547,9 @@ export function getSvgPathFromStroke(stroke: number[][]) {
acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2) acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2)
return acc return acc
}, },
["M", ...stroke[0], "Q"] ['M', ...stroke[0], 'Q']
) )
d.push("Z") d.push('Z')
return d.join(" ") return d.join(' ')
} }

View file

@ -483,6 +483,6 @@ export function nudge(A: number[], B: number[], d: number) {
* @param a * @param a
* @param n * @param n
*/ */
export function toPrecision(a: number[], n = 3) { export function toPrecision(a: number[], n = 4) {
return [+a[0].toPrecision(n), +a[1].toPrecision(n)] return [+a[0].toPrecision(n), +a[1].toPrecision(n)]
} }

View file

@ -1358,6 +1358,17 @@
dependencies: dependencies:
"@babel/runtime" "^7.13.10" "@babel/runtime" "^7.13.10"
"@radix-ui/react-label@0.0.13":
version "0.0.13"
resolved "https://registry.yarnpkg.com/@radix-ui/react-label/-/react-label-0.0.13.tgz#b71930fa16a2cf859296317436cb88e31efb8ecf"
integrity sha512-csNElm8qA38pOHr772CXIvBXd/eCGaoAMImuLdawUxQNzwxQ4npd8lr/f9fi/4OLkgeNOVOqjsaVamiNmF/lIw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "0.0.5"
"@radix-ui/react-id" "0.0.6"
"@radix-ui/react-polymorphic" "0.0.11"
"@radix-ui/react-primitive" "0.0.13"
"@radix-ui/react-menu@0.0.18": "@radix-ui/react-menu@0.0.18":
version "0.0.18" version "0.0.18"
resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-0.0.18.tgz#b36f7657eb6757c623ffc688c48a4781ffd82351" resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-0.0.18.tgz#b36f7657eb6757c623ffc688c48a4781ffd82351"
@ -1431,6 +1442,24 @@
"@babel/runtime" "^7.13.10" "@babel/runtime" "^7.13.10"
"@radix-ui/react-polymorphic" "0.0.11" "@radix-ui/react-polymorphic" "0.0.11"
"@radix-ui/react-radio-group@^0.0.16":
version "0.0.16"
resolved "https://registry.yarnpkg.com/@radix-ui/react-radio-group/-/react-radio-group-0.0.16.tgz#10fc6e5c3102599cf422e9f6f8d2766088e602a1"
integrity sha512-vOtgflNWcauSul+EvnPCxATdmPw7fb1cuqBJX07yJdjbrw1Iv5v/+d79fNyIwPR+KrkhP+uCMIBfF0gvo6K7ZQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "0.0.5"
"@radix-ui/react-compose-refs" "0.0.5"
"@radix-ui/react-context" "0.0.5"
"@radix-ui/react-label" "0.0.13"
"@radix-ui/react-polymorphic" "0.0.11"
"@radix-ui/react-presence" "0.0.14"
"@radix-ui/react-primitive" "0.0.13"
"@radix-ui/react-roving-focus" "0.0.13"
"@radix-ui/react-slot" "0.0.10"
"@radix-ui/react-use-callback-ref" "0.0.5"
"@radix-ui/react-use-controllable-state" "0.0.6"
"@radix-ui/react-roving-focus@0.0.13": "@radix-ui/react-roving-focus@0.0.13":
version "0.0.13" version "0.0.13"
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-0.0.13.tgz#c72f503832577979c4caa9efcfd59140730c2f80" resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-0.0.13.tgz#c72f503832577979c4caa9efcfd59140730c2f80"