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.
if (!shape) return null
const center = getShapeUtils(shape).getCenter(shape)
const transform = `
rotate(${shape.rotation * (180 / Math.PI)},
${getShapeUtils(shape).getCenter(shape)})
rotate(${shape.rotation * (180 / Math.PI)}, ${center})
translate(${shape.point})`
return (
@ -38,18 +38,37 @@ function Shape({ id, isSelecting }: { id: string; isSelecting: boolean }) {
>
{isSelecting && <HoverIndicator as="use" href={'#' + id} />}
<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>
)
}
const StyledShape = memo(
({ 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', {
zStrokeWidth: 1,
// zStrokeWidth: 1,
})
const HoverIndicator = styled('path', {

View file

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

View file

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

View file

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

View file

@ -1,15 +1,17 @@
import styled from "styles"
import state, { useSelector } from "state"
import * as Panel from "components/panel"
import { useRef } from "react"
import { IconButton } from "components/shared"
import { Circle, Trash, X } from "react-feather"
import { deepCompare, deepCompareArrays, getSelectedShapes } from "utils/utils"
import { shades, fills, strokes } from "lib/colors"
import styled from 'styles'
import state, { useSelector } from 'state'
import * as Panel from 'components/panel'
import { useRef } from 'react'
import { IconButton } from 'components/shared'
import { Circle, Copy, Lock, Trash, Unlock, X } from 'react-feather'
import { deepCompare, deepCompareArrays, getSelectedShapes } from 'utils/utils'
import { shades, fills, strokes } from 'lib/colors'
import ColorPicker from "./color-picker"
import AlignDistribute from "./align-distribute"
import { ShapeStyles } from "types"
import ColorPicker from './color-picker'
import AlignDistribute from './align-distribute'
import { ShapeStyles } from 'types'
import WidthPicker from './width-picker'
import { CopyIcon } from '@radix-ui/react-icons'
const fillColors = { ...shades, ...fills }
const strokeColors = { ...shades, ...strokes }
@ -23,7 +25,7 @@ export default function StylePanel() {
{isOpen ? (
<SelectedShapeStyles />
) : (
<IconButton onClick={() => state.send("TOGGLED_STYLE_PANEL_OPEN")}>
<IconButton onClick={() => state.send('TOGGLED_STYLE_PANEL_OPEN')}>
<Circle />
</IconButton>
)}
@ -72,17 +74,9 @@ function SelectedShapeStyles({}: {}) {
return (
<Panel.Layout>
<Panel.Header>
<Panel.Header side="right">
<h3>Style</h3>
<Panel.ButtonsGroup>
<IconButton
disabled={!hasSelection}
onClick={() => state.send("DELETED")}
>
<Trash />
</IconButton>
</Panel.ButtonsGroup>
<IconButton onClick={() => state.send("TOGGLED_STYLE_PANEL_OPEN")}>
<IconButton onClick={() => state.send('TOGGLED_STYLE_PANEL_OPEN')}>
<X />
</IconButton>
</Panel.Header>
@ -91,18 +85,40 @@ function SelectedShapeStyles({}: {}) {
label="Fill"
color={shapesStyle.fill}
colors={fillColors}
onChange={(color) => state.send("CHANGED_STYLE", { fill: color })}
onChange={(color) => state.send('CHANGED_STYLE', { fill: color })}
/>
<ColorPicker
label="Stroke"
color={shapesStyle.stroke}
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
hasTwoOrMore={selectedIds.length > 1}
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>
</Panel.Layout>
)
@ -112,8 +128,8 @@ const StylePanelRoot = styled(Panel.Root, {
minWidth: 1,
width: 184,
maxWidth: 184,
overflow: "hidden",
position: "relative",
overflow: 'hidden',
position: 'relative',
variants: {
isOpen: {
@ -129,3 +145,41 @@ const StylePanelRoot = styled(Panel.Root, {
const Content = styled(Panel.Content, {
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 styled from "styles"
import { Menu } from "react-feather"
import state, { useSelector } from 'state'
import styled from 'styles'
import { Lock, Menu, Unlock } from 'react-feather'
import { IconButton } from './shared'
export default function Toolbar() {
const activeTool = useSelector((state) =>
state.whenIn({
selecting: "select",
dot: "dot",
circle: "circle",
ellipse: "ellipse",
ray: "ray",
line: "line",
polyline: "polyline",
rectangle: "rectangle",
draw: "draw",
selecting: 'select',
dot: 'dot',
circle: 'circle',
ellipse: 'ellipse',
ray: 'ray',
line: 'line',
polyline: 'polyline',
rectangle: 'rectangle',
draw: 'draw',
})
)
const isToolLocked = useSelector((s) => s.data.settings.isToolLocked)
return (
<ToolbarContainer>
<Section>
<Button>
<Menu />
</Button>
<Button onClick={() => state.send('TOGGLED_TOOL_LOCK')}>
{isToolLocked ? <Lock /> : <Unlock />}
</Button>
<Button
isSelected={activeTool === "select"}
onClick={() => state.send("SELECTED_SELECT_TOOL")}
isSelected={activeTool === 'select'}
onClick={() => state.send('SELECTED_SELECT_TOOL')}
>
Select
</Button>
<Button
isSelected={activeTool === "draw"}
onClick={() => state.send("SELECTED_DRAW_TOOL")}
isSelected={activeTool === 'draw'}
onClick={() => state.send('SELECTED_DRAW_TOOL')}
>
Draw
</Button>
<Button
isSelected={activeTool === "dot"}
onClick={() => state.send("SELECTED_DOT_TOOL")}
isSelected={activeTool === 'dot'}
onClick={() => state.send('SELECTED_DOT_TOOL')}
>
Dot
</Button>
<Button
isSelected={activeTool === "circle"}
onClick={() => state.send("SELECTED_CIRCLE_TOOL")}
isSelected={activeTool === 'circle'}
onClick={() => state.send('SELECTED_CIRCLE_TOOL')}
>
Circle
</Button>
<Button
isSelected={activeTool === "ellipse"}
onClick={() => state.send("SELECTED_ELLIPSE_TOOL")}
isSelected={activeTool === 'ellipse'}
onClick={() => state.send('SELECTED_ELLIPSE_TOOL')}
>
Ellipse
</Button>
<Button
isSelected={activeTool === "ray"}
onClick={() => state.send("SELECTED_RAY_TOOL")}
isSelected={activeTool === 'ray'}
onClick={() => state.send('SELECTED_RAY_TOOL')}
>
Ray
</Button>
<Button
isSelected={activeTool === "line"}
onClick={() => state.send("SELECTED_LINE_TOOL")}
isSelected={activeTool === 'line'}
onClick={() => state.send('SELECTED_LINE_TOOL')}
>
Line
</Button>
<Button
isSelected={activeTool === "polyline"}
onClick={() => state.send("SELECTED_POLYLINE_TOOL")}
isSelected={activeTool === 'polyline'}
onClick={() => state.send('SELECTED_POLYLINE_TOOL')}
>
Polyline
</Button>
<Button
isSelected={activeTool === "rectangle"}
onClick={() => state.send("SELECTED_RECTANGLE_TOOL")}
isSelected={activeTool === 'rectangle'}
onClick={() => state.send('SELECTED_RECTANGLE_TOOL')}
>
Rectangle
</Button>
<Button onClick={() => state.send("RESET_CAMERA")}>Reset Camera</Button>
<Button onClick={() => state.send('RESET_CAMERA')}>Reset Camera</Button>
</Section>
<Section>
<Button onClick={() => state.send("UNDO")}>Undo</Button>
<Button onClick={() => state.send("REDO")}>Redo</Button>
<Button onClick={() => state.send('UNDO')}>Undo</Button>
<Button onClick={() => state.send('REDO')}>Redo</Button>
</Section>
</ToolbarContainer>
)
}
const ToolbarContainer = styled("div", {
gridArea: "toolbar",
userSelect: "none",
borderBottom: "1px solid black",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: "$panel",
const ToolbarContainer = styled('div', {
gridArea: 'toolbar',
userSelect: 'none',
borderBottom: '1px solid black',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '$panel',
gap: 8,
fontSize: "$1",
fontSize: '$1',
zIndex: 200,
})
const Section = styled("div", {
whiteSpace: "nowrap",
overflow: "hidden",
display: "flex",
const Section = styled('div', {
whiteSpace: 'nowrap',
overflowY: 'hidden',
overflowX: 'auto',
display: 'flex',
scrollbarWidth: 'none',
'&::-webkit-scrollbar': {
'-webkit-appearance': 'none',
width: 0,
height: 0,
},
})
const Button = styled("button", {
display: "flex",
alignItems: "center",
cursor: "pointer",
font: "$ui",
fontSize: "$ui",
height: "40px",
outline: "none",
const Button = styled('button', {
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
font: '$ui',
fontSize: '$ui',
height: '40px',
outline: 'none',
borderRadius: 0,
border: "none",
padding: "0 12px",
background: "none",
"&:hover": {
backgroundColor: "$hint",
border: 'none',
padding: '0 12px',
background: 'none',
'&:hover': {
backgroundColor: '$hint',
},
"& svg": {
'& svg': {
height: 16,
width: 16,
},
variants: {
isSelected: {
true: {
color: "$selected",
color: '$selected',
},
false: {},
},

View file

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

View file

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

View file

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

View file

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

View file

@ -34,13 +34,13 @@ const draw = registerShapeUtils<DrawShape>({
strokeLinecap: 'round',
strokeLinejoin: 'round',
...props.style,
stroke: 'transparent',
fill: props.style.stroke,
},
}
},
render(shape) {
const { id, point, points } = shape
const { id, point, points, style } = shape
if (!pathCache.has(points)) {
if (points.length < 2) {
@ -51,7 +51,12 @@ const draw = registerShapeUtils<DrawShape>({
}
pathCache.set(points, getSvgPathFromStroke(d))
} 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) {
Object.assign(shape.style, style)
shape.style.fill = shape.style.stroke
return this
},
@ -128,7 +134,7 @@ const draw = registerShapeUtils<DrawShape>({
},
translateTo(shape, point) {
shape.point = point
shape.point = vec.toPrecision(point)
return this
},

View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +1,13 @@
import { v4 as uuid } from "uuid"
import * as vec from "utils/vec"
import { RectangleShape, ShapeType } from "types"
import { registerShapeUtils } from "./index"
import { boundsCollidePolygon, boundsContainPolygon } from "utils/bounds"
import { v4 as uuid } from 'uuid'
import * as vec from 'utils/vec'
import { RectangleShape, ShapeType } from 'types'
import { registerShapeUtils } from './index'
import { boundsCollidePolygon, boundsContainPolygon } from 'utils/bounds'
import {
getBoundsFromPoints,
getRotatedCorners,
translateBounds,
} from "utils/utils"
} from 'utils/utils'
const rectangle = registerShapeUtils<RectangleShape>({
boundsCache: new WeakMap([]),
@ -17,42 +17,31 @@ const rectangle = registerShapeUtils<RectangleShape>({
id: uuid(),
type: ShapeType.Rectangle,
isGenerated: false,
name: "Rectangle",
parentId: "page0",
name: 'Rectangle',
parentId: 'page0',
childIndex: 0,
point: [0, 0],
size: [1, 1],
radius: 2,
rotation: 0,
style: {
fill: "#c6cacb",
stroke: "#000",
fill: '#c6cacb',
stroke: '#000',
},
...props,
}
},
render({ id, size, radius, childIndex }) {
render({ id, size, radius, style }) {
return (
<g id={id}>
<rect
id={id}
width={size[0]}
height={size[1]}
rx={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>
)
},
@ -113,7 +102,7 @@ const rectangle = registerShapeUtils<RectangleShape>({
},
translateTo(shape, point) {
shape.point = point
shape.point = vec.toPrecision(point)
return this
},

View file

@ -11,6 +11,7 @@
"@monaco-editor/react": "^4.1.3",
"@radix-ui/react-dropdown-menu": "^0.0.19",
"@radix-ui/react-icons": "^1.0.3",
"@radix-ui/react-radio-group": "^0.0.16",
"@state-designer/react": "^1.7.1",
"@stitches/react": "^0.1.9",
"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 deleteSelected from "./delete-selected"
import direct from "./direct"
import distribute from "./distribute"
import generate from "./generate"
import move from "./move"
import draw from "./draw"
import rotate from "./rotate"
import stretch from "./stretch"
import style from "./style"
import transform from "./transform"
import transformSingle from "./transform-single"
import translate from "./translate"
import align from './align'
import deleteSelected from './delete-selected'
import direct from './direct'
import distribute from './distribute'
import duplicate from './duplicate'
import generate from './generate'
import move from './move'
import draw from './draw'
import rotate from './rotate'
import stretch from './stretch'
import style from './style'
import transform from './transform'
import transformSingle from './transform-single'
import translate from './translate'
import nudge from './nudge'
const commands = {
align,
deleteSelected,
direct,
distribute,
duplicate,
generate,
move,
draw,
@ -26,6 +29,7 @@ const commands = {
transform,
transformSingle,
translate,
nudge,
}
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 history from "../history"
import { Data, Corner, Edge } from "types"
import { getShapeUtils } from "lib/shape-utils"
import { current } from "immer"
import { TransformSingleSnapshot } from "state/sessions/transform-single-session"
import { getPage } from "utils/utils"
import Command from './command'
import history from '../history'
import { Data, Corner, Edge } from 'types'
import { getShapeUtils } from 'lib/shape-utils'
import { current } from 'immer'
import { TransformSingleSnapshot } from 'state/sessions/transform-single-session'
import { getPage } from 'utils/utils'
export default function transformSingleCommand(
data: Data,
@ -14,13 +14,13 @@ export default function transformSingleCommand(
scaleY: number,
isCreating: boolean
) {
const shape = getPage(data, after.currentPageId).shapes[after.id]
const shape = current(getPage(data, after.currentPageId).shapes[after.id])
history.execute(
data,
new Command({
name: "transform_single_shape",
category: "canvas",
name: 'transform_single_shape',
category: 'canvas',
manualSelection: true,
do(data) {
const { id, type, initialShape, initialShapeBounds } = after

View file

@ -41,10 +41,15 @@ const initialData: Data = {
isDarkMode: false,
isCodeOpen: false,
isStyleOpen: false,
isToolLocked: false,
isPenLocked: false,
nudgeDistanceLarge: 10,
nudgeDistanceSmall: 1,
},
currentStyle: {
fill: shades.lightGray,
stroke: shades.darkGray,
strokeWidth: 2,
},
camera: {
point: [0, 0],
@ -94,6 +99,9 @@ const state = createState({
else: 'zoomCameraToActual',
},
SELECTED_ALL: { to: 'selecting', do: 'selectAll' },
NUDGED: { do: 'nudgeSelection' },
USED_PEN_DEVICE: 'enablePenLock',
DISABLED_PEN_LOCK: 'disablePenLock',
},
initial: 'loading',
states: {
@ -124,20 +132,22 @@ const state = createState({
selecting: {
on: {
SAVED: 'forceSave',
UNDO: { do: 'undo' },
REDO: { do: 'redo' },
CANCELLED: { do: 'clearSelectedIds' },
DELETED: { do: 'deleteSelectedIds' },
UNDO: 'undo',
REDO: 'redo',
SAVED_CODE: 'saveCode',
GENERATED_FROM_CODE: ['setCodeControls', 'setGeneratedShapes'],
CANCELLED: 'clearSelectedIds',
DELETED: 'deleteSelectedIds',
STARTED_PINCHING: { to: 'pinching' },
INCREASED_CODE_FONT_SIZE: 'increaseCodeFontSize',
DECREASED_CODE_FONT_SIZE: 'decreaseCodeFontSize',
CHANGED_CODE_CONTROL: 'updateControls',
ALIGNED: 'alignSelection',
STRETCHED: 'stretchSelection',
DISTRIBUTED: 'distributeSelection',
MOVED: 'moveSelection',
STARTED_PINCHING: { to: 'pinching' },
GENERATED_FROM_CODE: ['setCodeControls', 'setGeneratedShapes'],
TOGGLED_TOOL_LOCK: 'toggleToolLock',
MOVED: { if: 'hasSelection', do: 'moveSelection' },
ALIGNED: { if: 'hasSelection', do: 'alignSelection' },
STRETCHED: { if: 'hasSelection', do: 'stretchSelection' },
DISTRIBUTED: { if: 'hasSelection', do: 'distributeSelection' },
DUPLICATED: { if: 'hasSelection', do: 'duplicateSelection' },
},
initial: 'notPointing',
states: {
@ -262,6 +272,9 @@ const state = createState({
PINCHED: { do: 'pinchCamera' },
},
},
usingTool: {
initial: 'draw',
states: {
draw: {
initial: 'creating',
states: {
@ -309,7 +322,16 @@ const state = createState({
},
editing: {
on: {
STOPPED_POINTING: { do: 'completeSession', to: 'selecting' },
STOPPED_POINTING: [
'completeSession',
{
if: 'isToolLocked',
to: 'dot.creating',
else: {
to: 'selecting',
},
},
],
CANCELLED: {
do: ['cancelSession', 'deleteSelectedIds'],
to: 'selecting',
@ -472,10 +494,14 @@ const state = createState({
},
drawingShape: {
on: {
STOPPED_POINTING: {
do: 'completeSession',
to: 'selecting',
STOPPED_POINTING: [
'completeSession',
{
if: 'isToolLocked',
to: 'usingTool.previous',
else: { to: 'selecting' },
},
],
CANCELLED: {
do: ['cancelSession', 'deleteSelectedIds'],
to: 'selecting',
@ -500,6 +526,8 @@ const state = createState({
},
},
},
},
},
results: {
newDraw() {
return ShapeType.Draw
@ -562,6 +590,12 @@ const state = createState({
hasSelection(data) {
return data.selectedIds.size > 0
},
isToolLocked(data) {
return data.settings.isToolLocked
},
isPenLocked(data) {
return data.settings.isPenLocked
},
},
actions: {
/* --------------------- Shapes --------------------- */
@ -712,6 +746,19 @@ const state = createState({
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 ------------------- */
selectAll(data) {
@ -756,6 +803,9 @@ const state = createState({
distributeSelection(data, payload: { type: DistributeType }) {
commands.distribute(data, payload.type)
},
duplicateSelection(data) {
commands.duplicate(data)
},
/* --------------------- Camera --------------------- */
@ -913,6 +963,7 @@ const state = createState({
},
/* ---------------------- Code ---------------------- */
closeCodePanel(data) {
data.settings.isCodeOpen = false
},
@ -962,7 +1013,20 @@ const state = createState({
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 }) {
data.document.code[data.currentCodeFileId].code = payload.code
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({
themeMap: {
@ -6,26 +6,27 @@ const { styled, global, css, theme, getCssString } = createCss({
},
theme: {
colors: {
brushFill: "rgba(0,0,0,.1)",
brushStroke: "rgba(0,0,0,.5)",
hint: "rgba(66, 133, 244, 0.200)",
selected: "rgba(66, 133, 244, 1.000)",
bounds: "rgba(65, 132, 244, 1.000)",
boundsBg: "rgba(65, 132, 244, 0.100)",
border: "#aaa",
panel: "#fefefe",
hover: "#efefef",
text: "#333",
input: "#f3f3f3",
inputBorder: "#ddd",
brushFill: 'rgba(0,0,0,.1)',
brushStroke: 'rgba(0,0,0,.5)',
hint: 'rgba(66, 133, 244, 0.200)',
selected: 'rgba(66, 133, 244, 1.000)',
bounds: 'rgba(65, 132, 244, 1.000)',
boundsBg: 'rgba(65, 132, 244, 0.100)',
border: '#aaa',
panel: '#fefefe',
inactive: '#cccccf',
hover: '#efefef',
text: '#333',
input: '#f3f3f3',
inputBorder: '#ddd',
},
space: {},
fontSizes: {
0: "10px",
1: "12px",
2: "13px",
3: "16px",
4: "18px",
0: '10px',
1: '12px',
2: '13px',
3: '16px',
4: '18px',
},
fonts: {
ui: '"Recursive", system-ui, sans-serif',
@ -72,17 +73,17 @@ const light = theme({})
const dark = theme({})
const globalStyles = global({
"*": { boxSizing: "border-box" },
":root": {
"--camera-zoom": 1,
"--scale": "calc(1 / var(--camera-zoom))",
'*': { boxSizing: 'border-box' },
':root': {
'--camera-zoom': 1,
'--scale': 'calc(1 / var(--camera-zoom))',
},
"html, body": {
padding: "0px",
margin: "0px",
overscrollBehavior: "none",
fontFamily: "$ui",
fontSize: "$2",
'html, body': {
padding: '0px',
margin: '0px',
overscrollBehavior: 'none',
fontFamily: '$ui',
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 */
@ -13,6 +13,10 @@ export interface Data {
isDarkMode: boolean
isCodeOpen: boolean
isStyleOpen: boolean
nudgeDistanceSmall: number
nudgeDistanceLarge: number
isToolLocked: boolean
isPenLocked: boolean
}
currentStyle: ShapeStyles
camera: {
@ -39,21 +43,21 @@ export interface Data {
export interface Page {
id: string
type: "page"
type: 'page'
childIndex: number
name: string
shapes: Record<string, Shape>
}
export enum ShapeType {
Dot = "dot",
Circle = "circle",
Ellipse = "ellipse",
Line = "line",
Ray = "ray",
Polyline = "polyline",
Rectangle = "rectangle",
Draw = "draw",
Dot = 'dot',
Circle = 'circle',
Ellipse = 'ellipse',
Line = 'line',
Ray = 'ray',
Polyline = 'polyline',
Rectangle = 'rectangle',
Draw = 'draw',
}
// Consider:
@ -164,17 +168,17 @@ export interface PointerInfo {
}
export enum Edge {
Top = "top_edge",
Right = "right_edge",
Bottom = "bottom_edge",
Left = "left_edge",
Top = 'top_edge',
Right = 'right_edge',
Bottom = 'bottom_edge',
Left = 'left_edge',
}
export enum Corner {
TopLeft = "top_left_corner",
TopRight = "top_right_corner",
BottomRight = "bottom_right_corner",
BottomLeft = "bottom_left_corner",
TopLeft = 'top_left_corner',
TopRight = 'top_right_corner',
BottomRight = 'bottom_right_corner',
BottomLeft = 'bottom_left_corner',
}
export interface Bounds {
@ -262,10 +266,10 @@ export type IMonaco = typeof monaco
export type IMonacoEditor = monaco.editor.IStandaloneCodeEditor
export enum ControlType {
Number = "number",
Vector = "vector",
Text = "text",
Select = "select",
Number = 'number',
Vector = 'vector',
Text = 'text',
Select = 'select',
}
export interface BaseCodeControl {
@ -296,7 +300,7 @@ export interface TextCodeControl extends BaseCodeControl {
format?: (value: string) => string
}
export interface SelectCodeControl<T extends string = "">
export interface SelectCodeControl<T extends string = ''>
extends BaseCodeControl {
type: ControlType.Select
value: T

View file

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

View file

@ -483,6 +483,6 @@ export function nudge(A: number[], B: number[], d: number) {
* @param a
* @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)]
}

View file

@ -1358,6 +1358,17 @@
dependencies:
"@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":
version "0.0.18"
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"
"@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":
version "0.0.13"
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-0.0.13.tgz#c72f503832577979c4caa9efcfd59140730c2f80"