Adds dashes

This commit is contained in:
Steve Ruiz 2021-06-01 22:49:32 +01:00
parent ea996b627b
commit 815bf1109c
29 changed files with 698 additions and 233 deletions

View file

@ -18,9 +18,9 @@ export default function Handles() {
selectedIds.length === 1 && getPage(data).shapes[selectedIds[0]] selectedIds.length === 1 && getPage(data).shapes[selectedIds[0]]
) )
const isTranslatingHandles = useSelector((s) => s.isIn('translatingHandles')) const isSelecting = useSelector((s) => s.isIn('selecting.notPointing'))
if (!shape.handles || isTranslatingHandles) return null if (!shape.handles || !isSelecting) return null
return ( return (
<g> <g>
@ -57,7 +57,7 @@ function Handle({
pointerEvents="all" pointerEvents="all"
transform={`translate(${point})`} transform={`translate(${point})`}
> >
<HandleCircleOuter r={8} /> <HandleCircleOuter r={12} />
<DotCircle r={4} /> <DotCircle r={4} />
</g> </g>
) )

View file

@ -34,10 +34,20 @@ export default function Canvas() {
} else { } else {
if (isMobile()) { if (isMobile()) {
state.send('TOUCHED_CANVAS') state.send('TOUCHED_CANVAS')
// state.send('POINTED_CANVAS', inputs.touchStart(e, 'canvas'))
// e.preventDefault()
// e.stopPropagation()
} }
} }
}, []) }, [])
// const handleTouchMove = useCallback((e: React.TouchEvent) => {
// if (!inputs.canAccept(e.touches[0].identifier)) return
// if (inputs.canAccept(e.touches[0].identifier)) {
// state.send('MOVED_POINTER', inputs.touchMove(e))
// }
// }, [])
const handlePointerMove = useCallback((e: React.PointerEvent) => { const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return if (!inputs.canAccept(e.pointerId)) return
if (inputs.canAccept(e.pointerId)) { if (inputs.canAccept(e.pointerId)) {
@ -58,6 +68,7 @@ export default function Canvas() {
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
// onTouchMove={handleTouchMove}
> >
<Defs /> <Defs />
{isReady && ( {isReady && (

View file

@ -39,7 +39,7 @@ export function ShapeOutline({ id }: { id: string }) {
` `
return ( return (
<Indicator <SelectIndicator
ref={rIndicator} ref={rIndicator}
as="use" as="use"
href={'#' + id} href={'#' + id}
@ -50,13 +50,14 @@ export function ShapeOutline({ id }: { id: string }) {
) )
} }
const Indicator = styled('path', { const SelectIndicator = styled('path', {
zStrokeWidth: 1, zStrokeWidth: 3,
strokeLineCap: 'round', strokeLineCap: 'round',
strokeLinejoin: 'round', strokeLinejoin: 'round',
stroke: '$selected', stroke: '$selected',
fill: 'transparent', fill: 'transparent',
pointerEvents: 'all', pointerEvents: 'none',
paintOrder: 'stroke fill markers',
variants: { variants: {
isLocked: { isLocked: {
@ -65,5 +66,6 @@ const Indicator = styled('path', {
}, },
false: {}, false: {},
}, },
variant: {},
}, },
}) })

View file

@ -3,8 +3,9 @@ import { useSelector } from 'state'
import styled from 'styles' import styled from 'styles'
import { getShapeUtils } from 'lib/shape-utils' import { getShapeUtils } from 'lib/shape-utils'
import { getPage } from 'utils/utils' import { getPage } from 'utils/utils'
import { ShapeStyles } from 'types' import { DashStyle, ShapeStyles } from 'types'
import useShapeEvents from 'hooks/useShapeEvents' import useShapeEvents from 'hooks/useShapeEvents'
import { shades, strokes } from 'lib/colors'
function Shape({ id, isSelecting }: { id: string; isSelecting: boolean }) { function Shape({ id, isSelecting }: { id: string; isSelecting: boolean }) {
const isHovered = useSelector((state) => state.data.hoveredId === id) const isHovered = useSelector((state) => state.data.hoveredId === id)
@ -35,36 +36,61 @@ function Shape({ id, isSelecting }: { id: string; isSelecting: boolean }) {
isHovered={isHovered} isHovered={isHovered}
isSelected={isSelected} isSelected={isSelected}
transform={transform} transform={transform}
{...events} stroke={'red'}
strokeWidth={10}
> >
{isSelecting && ( {isSelecting && (
<HoverIndicator <HoverIndicator
as="use" as="use"
href={'#' + id} href={'#' + id}
strokeWidth={+shape.style.strokeWidth + 8} strokeWidth={+shape.style.strokeWidth + 8}
variant={shape.style.fill === 'none' ? 'hollow' : 'filled'}
{...events}
/> />
)} )}
{!shape.isHidden && <StyledShape id={id} style={shape.style} />} {!shape.isHidden && (
<RealShape id={id} style={sanitizeStyle(shape.style)} />
)}
</StyledGroup> </StyledGroup>
) )
} }
const StyledShape = memo( const RealShape = memo(({ id, style }: { id: string; style: ShapeStyles }) => {
({ id, style }: { id: string; style: ShapeStyles }) => { return (
return <use href={'#' + id} {...style} /> <StyledShape
} as="use"
) href={'#' + id}
{...style}
strokeDasharray={getDash(style.dash, +style.strokeWidth)}
/>
)
})
const StyledShape = styled('path', {
strokeLinecap: 'round',
strokeLinejoin: 'round',
})
const HoverIndicator = styled('path', { const HoverIndicator = styled('path', {
fill: 'none', fill: 'transparent',
stroke: 'transparent', stroke: 'transparent',
pointerEvents: 'all',
strokeLinecap: 'round', strokeLinecap: 'round',
strokeLinejoin: 'round', strokeLinejoin: 'round',
transform: 'all .2s', transform: 'all .2s',
variants: {
variant: {
hollow: {
pointerEvents: 'stroke',
},
filled: {
pointerEvents: 'all',
},
},
},
}) })
const StyledGroup = styled('g', { const StyledGroup = styled('g', {
pointerEvents: 'none',
[`& ${HoverIndicator}`]: { [`& ${HoverIndicator}`]: {
opacity: '0', opacity: '0',
}, },
@ -84,10 +110,8 @@ const StyledGroup = styled('g', {
isHovered: true, isHovered: true,
css: { css: {
[`& ${HoverIndicator}`]: { [`& ${HoverIndicator}`]: {
opacity: '1', opacity: '.4',
stroke: '$hint', stroke: '$selected',
fill: '$hint',
// zStrokeWidth: [8, 4],
}, },
}, },
}, },
@ -96,10 +120,8 @@ const StyledGroup = styled('g', {
isHovered: false, isHovered: false,
css: { css: {
[`& ${HoverIndicator}`]: { [`& ${HoverIndicator}`]: {
opacity: '1', opacity: '.2',
stroke: '$hint', stroke: '$selected',
fill: '$hint',
// zStrokeWidth: [6, 3],
}, },
}, },
}, },
@ -108,10 +130,8 @@ const StyledGroup = styled('g', {
isHovered: true, isHovered: true,
css: { css: {
[`& ${HoverIndicator}`]: { [`& ${HoverIndicator}`]: {
opacity: '1', opacity: '.2',
stroke: '$hint', stroke: '$selected',
fill: '$hint',
// zStrokeWidth: [8, 4],
}, },
}, },
}, },
@ -134,6 +154,25 @@ function Label({ text }: { text: string }) {
) )
} }
function getDash(dash: DashStyle, s: number) {
switch (dash) {
case DashStyle.Solid: {
return 'none'
}
case DashStyle.Dashed: {
return `${s} ${s * 2}`
}
case DashStyle.Dotted: {
return `0 ${s * 1.5}`
}
}
}
function sanitizeStyle(style: ShapeStyles) {
const next = { ...style }
return next
}
export { HoverIndicator } export { HoverIndicator }
export default memo(Shape) export default memo(Shape)

View file

@ -33,7 +33,7 @@ export default function Editor() {
) )
} }
const Layout = styled('div', { const Layout = styled('main', {
position: 'fixed', position: 'fixed',
top: 0, top: 0,
left: 0, left: 0,
@ -51,20 +51,24 @@ const Layout = styled('div', {
`, `,
}) })
const LeftPanels = styled('main', { const LeftPanels = styled('div', {
display: 'grid', display: 'grid',
gridArea: 'leftPanels', gridArea: 'leftPanels',
gridTemplateRows: '1fr auto', gridTemplateRows: '1fr auto',
padding: 8, padding: 8,
gap: 8, gap: 8,
zIndex: 250,
pointerEvents: 'none',
}) })
const RightPanels = styled('main', { const RightPanels = styled('div', {
gridArea: 'rightPanels', gridArea: 'rightPanels',
padding: 8, padding: 8,
// display: 'grid', display: 'grid',
// gridTemplateRows: 'auto', gridTemplateRows: 'auto',
// height: 'fit-content', height: 'fit-content',
// justifyContent: 'flex-end', justifyContent: 'flex-end',
// gap: 8, gap: 8,
zIndex: 300,
pointerEvents: 'none',
}) })

View file

@ -14,7 +14,7 @@ export default function ColorPicker({ colors, onChange, children }: Props) {
{children} {children}
<Colors sideOffset={4}> <Colors sideOffset={4}>
{Object.entries(colors).map(([name, color]) => ( {Object.entries(colors).map(([name, color]) => (
<ColorButton key={name} title={name} onSelect={() => onChange(color)}> <ColorButton key={name} title={name} onSelect={() => onChange(name)}>
<ColorIcon color={color} /> <ColorIcon color={color} />
</ColorButton> </ColorButton>
))} ))}
@ -29,7 +29,7 @@ export function ColorIcon({ color }: { color: string }) {
) )
} }
const Colors = styled(DropdownMenu.Content, { export const Colors = styled(DropdownMenu.Content, {
display: 'grid', display: 'grid',
padding: 4, padding: 4,
gridTemplateColumns: 'repeat(6, 1fr)', gridTemplateColumns: 'repeat(6, 1fr)',
@ -117,4 +117,13 @@ export const CurrentColor = styled(DropdownMenu.Trigger, {
strokeWidth: 1, strokeWidth: 1,
zIndex: 1, zIndex: 1,
}, },
variants: {
size: {
icon: {
padding: '4px ',
width: 'auto',
},
},
},
}) })

View file

@ -0,0 +1,64 @@
import { Group, RadioItem } from './shared'
import { DashStyle } from 'types'
import state from 'state'
import { ChangeEvent } from 'react'
function handleChange(e: ChangeEvent<HTMLInputElement>) {
state.send('CHANGED_STYLE', {
dash: e.currentTarget.value,
})
}
interface Props {
dash: DashStyle
}
export default function DashPicker({ dash }: Props) {
return (
<Group name="Dash" onValueChange={handleChange}>
<RadioItem value={DashStyle.Solid} isActive={dash === DashStyle.Solid}>
<DashSolidIcon />
</RadioItem>
<RadioItem value={DashStyle.Dashed} isActive={dash === DashStyle.Dashed}>
<DashDashedIcon />
</RadioItem>
<RadioItem value={DashStyle.Dotted} isActive={dash === DashStyle.Dotted}>
<DashDottedIcon />
</RadioItem>
</Group>
)
}
function DashSolidIcon() {
return (
<svg width="16" height="16">
<path d="M 3,8 L 13,8" strokeWidth={3} strokeLinecap="round" />
</svg>
)
}
function DashDashedIcon() {
return (
<svg width="16" height="16">
<path
d="M 2,8 L 14,8"
strokeWidth={3}
strokeLinecap="round"
strokeDasharray="4 4"
/>
</svg>
)
}
function DashDottedIcon() {
return (
<svg width="16" height="16">
<path
d="M 3,8 L 14,8"
strokeWidth={3}
strokeLinecap="round"
strokeDasharray="1 4"
/>
</svg>
)
}

View file

@ -0,0 +1,76 @@
import * as RadioGroup from '@radix-ui/react-radio-group'
import * as Panel from '../panel'
import styled from 'styles'
export const StylePanelRoot = styled(Panel.Root, {
minWidth: 1,
width: 184,
maxWidth: 184,
overflow: 'hidden',
position: 'relative',
border: '1px solid $panel',
boxShadow: '0px 2px 4px rgba(0,0,0,.12)',
variants: {
isOpen: {
true: {},
false: {
padding: 2,
height: 38,
width: 38,
},
},
},
})
export const Group = styled(RadioGroup.Root, {
display: 'flex',
})
export 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': {
stroke: '$text',
fill: '$text',
strokeWidth: '0',
},
},
'&:disabled': {
opacity: '0.5',
},
variants: {
isActive: {
true: {
'& svg': {
fill: '$text',
stroke: '$text',
strokeWidth: '0',
},
},
false: {
'& svg': {
fill: '$inactive',
stroke: '$inactive',
strokeWidth: '0',
},
},
},
},
})

View file

@ -3,27 +3,22 @@ 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, Copy, Lock, Trash, Trash2, Unlock, X } from 'react-feather' import * as Checkbox from '@radix-ui/react-checkbox'
import { import { Trash2, X } from 'react-feather'
deepCompare, import { deepCompare, deepCompareArrays, getPage } from 'utils/utils'
deepCompareArrays,
getPage,
getSelectedShapes,
} from 'utils/utils'
import { shades, fills, strokes } from 'lib/colors' import { shades, fills, strokes } from 'lib/colors'
import ColorPicker, { ColorIcon, CurrentColor } from './color-picker' import ColorPicker, { ColorIcon, CurrentColor } from './color-picker'
import AlignDistribute from './align-distribute' import AlignDistribute from './align-distribute'
import { MoveType, ShapeStyles } from 'types' import { MoveType, ShapeStyles } from 'types'
import WidthPicker from './width-picker' import WidthPicker from './width-picker'
import { import {
AlignTopIcon,
ArrowDownIcon, ArrowDownIcon,
ArrowUpIcon, ArrowUpIcon,
AspectRatioIcon, AspectRatioIcon,
BoxIcon, BoxIcon,
CheckIcon,
CopyIcon, CopyIcon,
DotsHorizontalIcon, DotsVerticalIcon,
EyeClosedIcon, EyeClosedIcon,
EyeOpenIcon, EyeOpenIcon,
LockClosedIcon, LockClosedIcon,
@ -31,11 +26,17 @@ import {
PinBottomIcon, PinBottomIcon,
PinTopIcon, PinTopIcon,
RotateCounterClockwiseIcon, RotateCounterClockwiseIcon,
TrashIcon,
} from '@radix-ui/react-icons' } from '@radix-ui/react-icons'
import DashPicker from './dash-picker'
const fillColors = { ...shades, ...fills } const fillColors = { ...shades, ...fills }
const strokeColors = { ...shades, ...strokes } const strokeColors = { ...shades, ...strokes }
const getFillColor = (color: string) => {
if (shades[color]) {
return '#fff'
}
return fillColors[color]
}
export default function StylePanel() { export default function StylePanel() {
const rContainer = useRef<HTMLDivElement>(null) const rContainer = useRef<HTMLDivElement>(null)
@ -46,14 +47,41 @@ export default function StylePanel() {
{isOpen ? ( {isOpen ? (
<SelectedShapeStyles /> <SelectedShapeStyles />
) : ( ) : (
<IconButton onClick={() => state.send('TOGGLED_STYLE_PANEL_OPEN')}> <>
<DotsHorizontalIcon /> <QuickColorSelect prop="stroke" colors={strokeColors} />
<IconButton
title="Style"
onClick={() => state.send('TOGGLED_STYLE_PANEL_OPEN')}
>
<DotsVerticalIcon />
</IconButton> </IconButton>
</>
)} )}
</StylePanelRoot> </StylePanelRoot>
) )
} }
function QuickColorSelect({
prop,
colors,
}: {
prop: ShapeStyles['fill'] | ShapeStyles['stroke']
colors: Record<string, string>
}) {
const value = useSelector((s) => s.values.selectedStyle[prop])
return (
<ColorPicker
colors={colors}
onChange={(color) => state.send('CHANGED_STYLE', { [prop]: color })}
>
<CurrentColor size="icon" title={prop}>
<ColorIcon color={value} />
</CurrentColor>
</ColorPicker>
)
}
// This panel is going to be hard to keep cool, as we're selecting computed // This panel is going to be hard to keep cool, as we're selecting computed
// information, based on the user's current selection. We might have to keep // information, based on the user's current selection. We might have to keep
// track of this data manually within our state. // track of this data manually within our state.
@ -79,33 +107,7 @@ function SelectedShapeStyles({}: {}) {
return selectedIds.every((id) => page.shapes[id].isHidden) return selectedIds.every((id) => page.shapes[id].isHidden)
}) })
const commonStyle = useSelector((s) => { const commonStyle = useSelector((s) => s.values.selectedStyle, deepCompare)
const { currentStyle } = s.data
if (selectedIds.length === 0) {
return currentStyle
}
const page = getPage(s.data)
const shapeStyles = selectedIds.map((id) => page.shapes[id].style)
const commonStyle: Partial<ShapeStyles> = {}
const overrides = new Set<string>([])
for (const shapeStyle of shapeStyles) {
for (let key in currentStyle) {
if (overrides.has(key)) continue
if (commonStyle[key] === undefined) {
commonStyle[key] = shapeStyle[key]
} else {
if (commonStyle[key] === shapeStyle[key]) continue
commonStyle[key] = currentStyle[key]
overrides.add(key)
}
}
}
return commonStyle
}, deepCompare)
const hasSelection = selectedIds.length > 0 const hasSelection = selectedIds.length > 0
@ -118,28 +120,42 @@ function SelectedShapeStyles({}: {}) {
</IconButton> </IconButton>
</Panel.Header> </Panel.Header>
<Content> <Content>
<ColorPicker
colors={fillColors}
onChange={(color) => state.send('CHANGED_STYLE', { fill: color })}
>
<CurrentColor>
<label>Fill</label>
<ColorIcon color={commonStyle.fill} />
</CurrentColor>
</ColorPicker>
<ColorPicker <ColorPicker
colors={strokeColors} colors={strokeColors}
onChange={(color) => state.send('CHANGED_STYLE', { stroke: color })} onChange={(color) =>
state.send('CHANGED_STYLE', {
stroke: strokeColors[color],
fill: getFillColor(color),
})
}
> >
<CurrentColor> <CurrentColor>
<label>Stroke</label> <label>Color</label>
<ColorIcon color={commonStyle.stroke} /> <ColorIcon color={commonStyle.stroke} />
</CurrentColor> </CurrentColor>
</ColorPicker> </ColorPicker>
{/* <Row>
<label htmlFor="filled">Filled</label>
<StyledCheckbox
checked={commonStyle.isFilled}
onCheckedChange={(e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value)
state.send('CHANGED_STYLE', {
isFilled: e.target.value === 'on',
})
}}
>
<Checkbox.Indicator as={CheckIcon} />
</StyledCheckbox>
</Row>*/}
<Row> <Row>
<label htmlFor="width">Width</label> <label htmlFor="width">Width</label>
<WidthPicker strokeWidth={Number(commonStyle.strokeWidth)} /> <WidthPicker strokeWidth={Number(commonStyle.strokeWidth)} />
</Row> </Row>
<Row>
<label htmlFor="dash">Dash</label>
<DashPicker dash={commonStyle.dash} />
</Row>
<ButtonsRow> <ButtonsRow>
<IconButton <IconButton
disabled={!hasSelection} disabled={!hasSelection}
@ -221,14 +237,16 @@ const StylePanelRoot = styled(Panel.Root, {
position: 'relative', position: 'relative',
border: '1px solid $panel', border: '1px solid $panel',
boxShadow: '0px 2px 4px rgba(0,0,0,.12)', boxShadow: '0px 2px 4px rgba(0,0,0,.12)',
display: 'flex',
alignItems: 'center',
pointerEvents: 'all',
variants: { variants: {
isOpen: { isOpen: {
true: {}, true: {},
false: { false: {
padding: 2, padding: 2,
height: 38, width: 'fit-content',
width: 38,
}, },
}, },
}, },
@ -275,3 +293,22 @@ const ButtonsRow = styled('div', {
justifyContent: 'flex-start', justifyContent: 'flex-start',
padding: 4, padding: 4,
}) })
const StyledCheckbox = styled(Checkbox.Root, {
appearance: 'none',
backgroundColor: 'transparent',
border: 'none',
padding: 0,
boxShadow: 'inset 0 0 0 1px gainsboro',
width: 15,
height: 15,
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:focus': {
outline: 'none',
boxShadow: 'inset 0 0 0 1px dodgerblue, 0 0 0 1px dodgerblue',
},
})

View file

@ -1,10 +1,9 @@
import * as RadioGroup from '@radix-ui/react-radio-group' import { Group, RadioItem } from './shared'
import { ChangeEvent } from 'react' import { ChangeEvent } from 'react'
import { Circle } from 'react-feather' import { Circle } from 'react-feather'
import state from 'state' import state from 'state'
import styled from 'styles'
function setWidth(e: ChangeEvent<HTMLInputElement>) { function handleChange(e: ChangeEvent<HTMLInputElement>) {
state.send('CHANGED_STYLE', { state.send('CHANGED_STYLE', {
strokeWidth: Number(e.currentTarget.value), strokeWidth: Number(e.currentTarget.value),
}) })
@ -16,7 +15,7 @@ export default function WidthPicker({
strokeWidth?: number strokeWidth?: number
}) { }) {
return ( return (
<Group name="width" onValueChange={setWidth}> <Group name="width" onValueChange={handleChange}>
<RadioItem value="2" isActive={strokeWidth === 2}> <RadioItem value="2" isActive={strokeWidth === 2}>
<Circle size={6} /> <Circle size={6} />
</RadioItem> </RadioItem>
@ -29,52 +28,3 @@ export default function WidthPicker({
</Group> </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

@ -23,16 +23,16 @@ export const strokes = {
} }
export const fills = { export const fills = {
lime: 'rgba(217, 245, 162, 1.000)', lime: 'rgba(243, 252, 227, 1.000)',
green: 'rgba(177, 242, 188, 1.000)', green: 'rgba(235, 251, 238, 1.000)',
teal: 'rgba(149, 242, 215, 1.000)', teal: 'rgba(230, 252, 245, 1.000)',
cyan: 'rgba(153, 233, 242, 1.000)', cyan: 'rgba(227, 250, 251, 1.000)',
blue: 'rgba(166, 216, 255, 1.000)', blue: 'rgba(231, 245, 255, 1.000)',
indigo: 'rgba(186, 200, 255, 1.000)', indigo: 'rgba(237, 242, 255, 1.000)',
violet: 'rgba(208, 191, 255, 1.000)', violet: 'rgba(242, 240, 255, 1.000)',
grape: 'rgba(237, 190, 250, 1.000)', grape: 'rgba(249, 240, 252, 1.000)',
pink: 'rgba(252, 194, 215, 1.000)', pink: 'rgba(254, 241, 246, 1.000)',
red: 'rgba(255, 201, 201, 1.000)', red: 'rgba(255, 245, 245, 1.000)',
orange: 'rgba(255, 216, 168, 1.000)', orange: 'rgba(255, 244, 229, 1.000)',
yellow: 'rgba(255, 236, 153, 1.000)', yellow: 'rgba(255, 249, 219, 1.000)',
} }

View file

@ -3,14 +3,29 @@ import * as vec from 'utils/vec'
import * as svg from 'utils/svg' import * as svg from 'utils/svg'
import { ArrowShape, ShapeHandle, ShapeType } from 'types' import { ArrowShape, ShapeHandle, ShapeType } from 'types'
import { registerShapeUtils } from './index' import { registerShapeUtils } from './index'
import { circleFromThreePoints, clamp, getSweep } from 'utils/utils' import { circleFromThreePoints, clamp, isAngleBetween } from 'utils/utils'
import { boundsContained } from 'utils/bounds' import { pointInBounds } from 'utils/bounds'
import { intersectCircleBounds } from 'utils/intersections' import {
intersectArcBounds,
intersectLineSegmentBounds,
} from 'utils/intersections'
import { getBoundsFromPoints, translateBounds } from 'utils/utils' import { getBoundsFromPoints, translateBounds } from 'utils/utils'
import { pointInCircle } from 'utils/hitTests' import { pointInCircle } from 'utils/hitTests'
const ctpCache = new WeakMap<ArrowShape['handles'], number[]>() const ctpCache = new WeakMap<ArrowShape['handles'], number[]>()
function getCtp(shape: ArrowShape) {
if (!ctpCache.has(shape.handles)) {
const { start, end, bend } = shape.handles
ctpCache.set(
shape.handles,
circleFromThreePoints(start.point, end.point, bend.point)
)
}
return ctpCache.get(shape.handles)
}
const arrow = registerShapeUtils<ArrowShape>({ const arrow = registerShapeUtils<ArrowShape>({
boundsCache: new WeakMap([]), boundsCache: new WeakMap([]),
@ -69,7 +84,8 @@ const arrow = registerShapeUtils<ArrowShape>({
} }
}, },
render({ id, bend, points, handles, style }) { render(shape) {
const { id, bend, points, handles, style } = shape
const { start, end, bend: _bend } = handles const { start, end, bend: _bend } = handles
const arrowDist = vec.dist(start.point, end.point) const arrowDist = vec.dist(start.point, end.point)
@ -91,7 +107,7 @@ const arrow = registerShapeUtils<ArrowShape>({
) )
} }
const circle = showCircle && ctpCache.get(handles) const circle = showCircle && getCtp(shape)
return ( return (
<g id={id}> <g id={id}>
@ -114,12 +130,14 @@ const arrow = registerShapeUtils<ArrowShape>({
cy={start.point[1]} cy={start.point[1]}
r={+style.strokeWidth} r={+style.strokeWidth}
fill={style.stroke} fill={style.stroke}
strokeDasharray="none"
/> />
<polyline <polyline
points={[b, points[1], c].join()} points={[b, points[1], c].join()}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
strokeDasharray="none"
/> />
</g> </g>
) )
@ -127,6 +145,7 @@ const arrow = registerShapeUtils<ArrowShape>({
applyStyles(shape, style) { applyStyles(shape, style) {
Object.assign(shape.style, style) Object.assign(shape.style, style)
shape.style.fill = 'none'
return this return this
}, },
@ -159,24 +178,29 @@ const arrow = registerShapeUtils<ArrowShape>({
) )
} }
if (!ctpCache.has(shape.handles)) { const [cx, cy, r] = getCtp(shape)
ctpCache.set(
shape.handles,
circleFromThreePoints(start.point, end.point, bend.point)
)
}
const [cx, cy, r] = ctpCache.get(shape.handles)
return !pointInCircle(point, vec.add(shape.point, [cx, cy]), r - 4) return !pointInCircle(point, vec.add(shape.point, [cx, cy]), r - 4)
}, },
hitTestBounds(this, shape, brushBounds) { hitTestBounds(this, shape, brushBounds) {
const shapeBounds = this.getBounds(shape) const { start, end, bend } = shape.handles
return (
boundsContained(shapeBounds, brushBounds) || const sp = vec.add(shape.point, start.point)
intersectCircleBounds(shape.point, 4, brushBounds).length > 0 const ep = vec.add(shape.point, end.point)
)
if (pointInBounds(sp, brushBounds) || pointInBounds(ep, brushBounds)) {
return true
}
if (vec.isEqual(vec.med(start.point, end.point), bend.point)) {
return intersectLineSegmentBounds(sp, ep, brushBounds).length > 0
} else {
const [cx, cy, r] = getCtp(shape)
const cp = vec.add(shape.point, [cx, cy])
return intersectArcBounds(sp, ep, cp, r, brushBounds).length > 0
}
}, },
rotateTo(shape, rotation) { rotateTo(shape, rotation) {
@ -219,14 +243,7 @@ const arrow = registerShapeUtils<ArrowShape>({
start.point = shape.points[0] start.point = shape.points[0]
end.point = shape.points[1] end.point = shape.points[1]
const bendDist = (vec.dist(start.point, end.point) / 2) * shape.bend bend.point = getBendPoint(shape)
const midPoint = vec.med(start.point, end.point)
const u = vec.uni(vec.vec(start.point, end.point))
bend.point =
Math.abs(bendDist) > 10
? vec.add(midPoint, vec.mul(vec.per(u), bendDist))
: midPoint
shape.points = [shape.handles.start.point, shape.handles.end.point] shape.points = [shape.handles.start.point, shape.handles.end.point]
@ -244,8 +261,6 @@ const arrow = registerShapeUtils<ArrowShape>({
}, },
onHandleMove(shape, handles) { onHandleMove(shape, handles) {
const { start, end, bend } = shape.handles
for (let id in handles) { for (let id in handles) {
const handle = handles[id] const handle = handles[id]
@ -255,38 +270,35 @@ const arrow = registerShapeUtils<ArrowShape>({
shape.points[handle.index] = handle.point shape.points[handle.index] = handle.point
} }
const { start, end, bend } = shape.handles
const dist = vec.dist(start.point, end.point) const dist = vec.dist(start.point, end.point)
if (handle.id === 'bend') { if (handle.id === 'bend') {
const distance = vec.distanceToLineSegment(
start.point,
end.point,
handle.point,
true
)
shape.bend = clamp(distance / (dist / 2), -1, 1)
const a0 = vec.angle(handle.point, end.point)
const a1 = vec.angle(start.point, end.point)
if (a0 - a1 < 0) shape.bend *= -1
}
}
const dist = vec.dist(start.point, end.point)
const midPoint = vec.med(start.point, end.point) const midPoint = vec.med(start.point, end.point)
const bendDist = (dist / 2) * shape.bend
const u = vec.uni(vec.vec(start.point, end.point)) const u = vec.uni(vec.vec(start.point, end.point))
const ap = vec.add(midPoint, vec.mul(vec.per(u), dist / 2))
const bp = vec.sub(midPoint, vec.mul(vec.per(u), dist / 2))
shape.handles.bend.point = bend.point = vec.nearestPointOnLineSegment(ap, bp, bend.point, true)
Math.abs(bendDist) > 10 shape.bend = vec.dist(bend.point, midPoint) / (dist / 2)
? vec.add(midPoint, vec.mul(vec.per(u), bendDist))
: midPoint const sa = vec.angle(end.point, start.point)
const la = sa - Math.PI / 2
if (isAngleBetween(sa, la, vec.angle(end.point, bend.point))) {
shape.bend *= -1
}
}
}
shape.handles.bend.point = getBendPoint(shape)
return this return this
}, },
canTransform: true, canTransform: true,
canChangeAspectRatio: true, canChangeAspectRatio: true,
canStyleFill: false,
}) })
export default arrow export default arrow
@ -311,3 +323,16 @@ function getArrowArcPath(
end.point[1], end.point[1],
].join(' ') ].join(' ')
} }
function getBendPoint(shape: ArrowShape) {
const { start, end, bend } = shape.handles
const dist = vec.dist(start.point, end.point)
const midPoint = vec.med(start.point, end.point)
const bendDist = (dist / 2) * shape.bend
const u = vec.uni(vec.vec(start.point, end.point))
return Math.abs(bendDist) < 10
? midPoint
: vec.add(midPoint, vec.mul(vec.per(u), bendDist))
}

View file

@ -135,6 +135,7 @@ const circle = registerShapeUtils<CircleShape>({
canTransform: true, canTransform: true,
canChangeAspectRatio: false, canChangeAspectRatio: false,
canStyleFill: true,
}) })
export default circle export default circle

View file

@ -104,6 +104,7 @@ const dot = registerShapeUtils<DotShape>({
canTransform: false, canTransform: false,
canChangeAspectRatio: false, canChangeAspectRatio: false,
canStyleFill: true,
}) })
export default dot export default dot

View file

@ -11,6 +11,7 @@ import {
getSvgPathFromStroke, getSvgPathFromStroke,
translateBounds, translateBounds,
} from 'utils/utils' } from 'utils/utils'
import styled from 'styles'
const pathCache = new WeakMap<DrawShape['points'], string>([]) const pathCache = new WeakMap<DrawShape['points'], string>([])
@ -190,6 +191,11 @@ const draw = registerShapeUtils<DrawShape>({
canTransform: true, canTransform: true,
canChangeAspectRatio: true, canChangeAspectRatio: true,
canStyleFill: false,
}) })
export default draw export default draw
const DrawPath = styled('path', {
strokeWidth: 0,
})

View file

@ -149,6 +149,7 @@ const ellipse = registerShapeUtils<EllipseShape>({
canTransform: true, canTransform: true,
canChangeAspectRatio: true, canChangeAspectRatio: true,
canStyleFill: true,
}) })
export default ellipse export default ellipse

View file

@ -39,6 +39,9 @@ export interface ShapeUtility<K extends Readonly<Shape>> {
// Whether the shape's aspect ratio can change // Whether the shape's aspect ratio can change
canChangeAspectRatio: boolean canChangeAspectRatio: boolean
// Whether the shape's style can be filled
canStyleFill: boolean
// Create a new shape. // Create a new shape.
create(props: Partial<K>): K create(props: Partial<K>): K

View file

@ -113,6 +113,7 @@ const line = registerShapeUtils<LineShape>({
canTransform: false, canTransform: false,
canChangeAspectRatio: false, canChangeAspectRatio: false,
canStyleFill: false,
}) })
export default line export default line

View file

@ -137,6 +137,7 @@ const polyline = registerShapeUtils<PolylineShape>({
canTransform: true, canTransform: true,
canChangeAspectRatio: true, canChangeAspectRatio: true,
canStyleFill: false,
}) })
export default polyline export default polyline

View file

@ -112,6 +112,7 @@ const ray = registerShapeUtils<RayShape>({
canTransform: false, canTransform: false,
canChangeAspectRatio: false, canChangeAspectRatio: false,
canStyleFill: false,
}) })
export default ray export default ray

View file

@ -150,6 +150,7 @@ const rectangle = registerShapeUtils<RectangleShape>({
canTransform: true, canTransform: true,
canChangeAspectRatio: true, canChangeAspectRatio: true,
canStyleFill: true,
}) })
export default rectangle export default rectangle

View file

@ -9,6 +9,7 @@
}, },
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.1.3", "@monaco-editor/react": "^4.1.3",
"@radix-ui/react-checkbox": "^0.0.15",
"@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", "@radix-ui/react-radio-group": "^0.0.16",

View file

@ -1,3 +1,4 @@
import React from 'react'
import { PointerInfo } from 'types' import { PointerInfo } from 'types'
import { isDarwin } from 'utils/utils' import { isDarwin } from 'utils/utils'
@ -5,6 +6,52 @@ class Inputs {
activePointerId?: number activePointerId?: number
points: Record<string, PointerInfo> = {} points: Record<string, PointerInfo> = {}
touchStart(e: TouchEvent | React.TouchEvent, target: string) {
const { shiftKey, ctrlKey, metaKey, altKey } = e
const touch = e.changedTouches[0]
const info = {
target,
pointerId: touch.identifier,
origin: [touch.clientX, touch.clientY],
point: [touch.clientX, touch.clientY],
shiftKey,
ctrlKey,
metaKey: isDarwin() ? metaKey : ctrlKey,
altKey,
}
this.points[touch.identifier] = info
this.activePointerId = touch.identifier
return info
}
touchMove(e: TouchEvent | React.TouchEvent) {
const { shiftKey, ctrlKey, metaKey, altKey } = e
const touch = e.changedTouches[0]
const prev = this.points[touch.identifier]
const info = {
...prev,
pointerId: touch.identifier,
point: [touch.clientX, touch.clientY],
shiftKey,
ctrlKey,
metaKey: isDarwin() ? metaKey : ctrlKey,
altKey,
}
if (this.points[touch.identifier]) {
this.points[touch.identifier] = info
}
return info
}
pointerDown(e: PointerEvent | React.PointerEvent, target: string) { pointerDown(e: PointerEvent | React.PointerEvent, target: string) {
const { shiftKey, ctrlKey, metaKey, altKey } = e const { shiftKey, ctrlKey, metaKey, altKey } = e

View file

@ -15,6 +15,7 @@ import {
getCurrent, getCurrent,
getPage, getPage,
getSelectedBounds, getSelectedBounds,
getSelectedShapes,
getShape, getShape,
screenToWorld, screenToWorld,
setZoomCSS, setZoomCSS,
@ -32,6 +33,7 @@ import {
DistributeType, DistributeType,
AlignType, AlignType,
StretchType, StretchType,
DashStyle,
} from 'types' } from 'types'
const initialData: Data = { const initialData: Data = {
@ -50,6 +52,7 @@ const initialData: Data = {
fill: shades.lightGray, fill: shades.lightGray,
stroke: shades.darkGray, stroke: shades.darkGray,
strokeWidth: 2, strokeWidth: 2,
dash: DashStyle.Solid,
}, },
camera: { camera: {
point: [0, 0], point: [0, 0],
@ -1296,6 +1299,35 @@ const state = createState({
...shapes.map((shape) => getShapeUtils(shape).getRotatedBounds(shape)) ...shapes.map((shape) => getShapeUtils(shape).getRotatedBounds(shape))
) )
}, },
selectedStyle(data) {
const selectedIds = Array.from(data.selectedIds.values())
const { currentStyle } = data
if (selectedIds.length === 0) {
return currentStyle
}
const page = getPage(data)
const shapeStyles = selectedIds.map((id) => page.shapes[id].style)
const commonStyle: Partial<ShapeStyles> = {}
const overrides = new Set<string>([])
for (const shapeStyle of shapeStyles) {
for (let key in currentStyle) {
if (overrides.has(key)) continue
if (commonStyle[key] === undefined) {
commonStyle[key] = shapeStyle[key]
} else {
if (commonStyle[key] === shapeStyle[key]) continue
commonStyle[key] = currentStyle[key]
overrides.add(key)
}
}
}
return commonStyle
},
}, },
}) })

View file

@ -8,7 +8,7 @@ const { styled, global, css, theme, getCssString } = createCss({
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(216, 226, 249, 1.000)',
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)',

View file

@ -67,7 +67,11 @@ export enum ShapeType {
// Cubic = "cubic", // Cubic = "cubic",
// Conic = "conic", // Conic = "conic",
export type ShapeStyles = Partial<React.SVGProps<SVGUseElement>> export type ShapeStyles = Partial<
React.SVGProps<SVGUseElement> & {
dash: DashStyle
}
>
export interface BaseShape { export interface BaseShape {
id: string id: string
@ -173,7 +177,13 @@ export interface CodeFile {
} }
export enum Decoration { export enum Decoration {
Arrow, Arrow = 'Arrow',
}
export enum DashStyle {
Solid = 'Solid',
Dashed = 'Dashed',
Dotted = 'Dotted',
} }
export interface ShapeBinding { export interface ShapeBinding {

View file

@ -1,5 +1,6 @@
import { Bounds } from "types" import { Bounds } from 'types'
import * as vec from "utils/vec" import * as vec from 'utils/vec'
import { isAngleBetween } from './utils'
interface Intersection { interface Intersection {
didIntersect: boolean didIntersect: boolean
@ -26,22 +27,22 @@ export function intersectLineSegments(
const u_b = BV[1] * AV[0] - BV[0] * AV[1] const u_b = BV[1] * AV[0] - BV[0] * AV[1]
if (ua_t === 0 || ub_t === 0) { if (ua_t === 0 || ub_t === 0) {
return getIntersection("coincident") return getIntersection('coincident')
} }
if (u_b === 0) { if (u_b === 0) {
return getIntersection("parallel") return getIntersection('parallel')
} }
if (u_b != 0) { if (u_b != 0) {
const ua = ua_t / u_b const ua = ua_t / u_b
const ub = ub_t / u_b const ub = ub_t / u_b
if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) { if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) {
return getIntersection("intersection", vec.add(a1, vec.mul(AV, ua))) return getIntersection('intersection', vec.add(a1, vec.mul(AV, ua)))
} }
} }
return getIntersection("no intersection") return getIntersection('no intersection')
} }
export function intersectCircleLineSegment( export function intersectCircleLineSegment(
@ -65,11 +66,11 @@ export function intersectCircleLineSegment(
const deter = b * b - 4 * a * cc const deter = b * b - 4 * a * cc
if (deter < 0) { if (deter < 0) {
return getIntersection("outside") return getIntersection('outside')
} }
if (deter === 0) { if (deter === 0) {
return getIntersection("tangent") return getIntersection('tangent')
} }
var e = Math.sqrt(deter) var e = Math.sqrt(deter)
@ -77,9 +78,9 @@ export function intersectCircleLineSegment(
var u2 = (-b - e) / (2 * a) var u2 = (-b - e) / (2 * a)
if ((u1 < 0 || u1 > 1) && (u2 < 0 || u2 > 1)) { if ((u1 < 0 || u1 > 1) && (u2 < 0 || u2 > 1)) {
if ((u1 < 0 && u2 < 0) || (u1 > 1 && u2 > 1)) { if ((u1 < 0 && u2 < 0) || (u1 > 1 && u2 > 1)) {
return getIntersection("outside") return getIntersection('outside')
} else { } else {
return getIntersection("inside") return getIntersection('inside')
} }
} }
@ -87,7 +88,7 @@ export function intersectCircleLineSegment(
if (0 <= u1 && u1 <= 1) results.push(vec.lrp(a1, a2, u1)) if (0 <= u1 && u1 <= 1) results.push(vec.lrp(a1, a2, u1))
if (0 <= u2 && u2 <= 1) results.push(vec.lrp(a1, a2, u2)) if (0 <= u2 && u2 <= 1) results.push(vec.lrp(a1, a2, u2))
return getIntersection("intersection", ...results) return getIntersection('intersection', ...results)
} }
export function intersectEllipseLineSegment( export function intersectEllipseLineSegment(
@ -100,7 +101,7 @@ export function intersectEllipseLineSegment(
) { ) {
// If the ellipse or line segment are empty, return no tValues. // If the ellipse or line segment are empty, return no tValues.
if (rx === 0 || ry === 0 || vec.isEqual(a1, a2)) { if (rx === 0 || ry === 0 || vec.isEqual(a1, a2)) {
return getIntersection("No intersection") return getIntersection('No intersection')
} }
// Get the semimajor and semiminor axes. // Get the semimajor and semiminor axes.
@ -141,7 +142,32 @@ export function intersectEllipseLineSegment(
.map((t) => vec.add(center, vec.add(a1, vec.mul(vec.sub(a2, a1), t)))) .map((t) => vec.add(center, vec.add(a1, vec.mul(vec.sub(a2, a1), t))))
.map((p) => vec.rotWith(p, center, rotation)) .map((p) => vec.rotWith(p, center, rotation))
return getIntersection("intersection", ...points) return getIntersection('intersection', ...points)
}
export function intersectArcLineSegment(
start: number[],
end: number[],
center: number[],
radius: number,
A: number[],
B: number[]
) {
const sa = vec.angle(center, start)
const ea = vec.angle(center, end)
const ellipseTest = intersectEllipseLineSegment(center, radius, radius, A, B)
if (!ellipseTest.didIntersect) return getIntersection('No intersection')
const points = ellipseTest.points.filter((point) =>
isAngleBetween(sa, ea, vec.angle(center, point))
)
if (points.length === 0) {
return getIntersection('No intersection')
}
return getIntersection('intersection', ...points)
} }
export function intersectCircleRectangle( export function intersectCircleRectangle(
@ -163,19 +189,19 @@ export function intersectCircleRectangle(
const leftIntersection = intersectCircleLineSegment(c, r, tl, bl) const leftIntersection = intersectCircleLineSegment(c, r, tl, bl)
if (topIntersection.didIntersect) { if (topIntersection.didIntersect) {
intersections.push({ ...topIntersection, message: "top" }) intersections.push({ ...topIntersection, message: 'top' })
} }
if (rightIntersection.didIntersect) { if (rightIntersection.didIntersect) {
intersections.push({ ...rightIntersection, message: "right" }) intersections.push({ ...rightIntersection, message: 'right' })
} }
if (bottomIntersection.didIntersect) { if (bottomIntersection.didIntersect) {
intersections.push({ ...bottomIntersection, message: "bottom" }) intersections.push({ ...bottomIntersection, message: 'bottom' })
} }
if (leftIntersection.didIntersect) { if (leftIntersection.didIntersect) {
intersections.push({ ...leftIntersection, message: "left" }) intersections.push({ ...leftIntersection, message: 'left' })
} }
return intersections return intersections
@ -230,19 +256,19 @@ export function intersectEllipseRectangle(
) )
if (topIntersection.didIntersect) { if (topIntersection.didIntersect) {
intersections.push({ ...topIntersection, message: "top" }) intersections.push({ ...topIntersection, message: 'top' })
} }
if (rightIntersection.didIntersect) { if (rightIntersection.didIntersect) {
intersections.push({ ...rightIntersection, message: "right" }) intersections.push({ ...rightIntersection, message: 'right' })
} }
if (bottomIntersection.didIntersect) { if (bottomIntersection.didIntersect) {
intersections.push({ ...bottomIntersection, message: "bottom" }) intersections.push({ ...bottomIntersection, message: 'bottom' })
} }
if (leftIntersection.didIntersect) { if (leftIntersection.didIntersect) {
intersections.push({ ...leftIntersection, message: "left" }) intersections.push({ ...leftIntersection, message: 'left' })
} }
return intersections return intersections
@ -267,19 +293,86 @@ export function intersectRectangleLineSegment(
const leftIntersection = intersectLineSegments(a1, a2, tl, bl) const leftIntersection = intersectLineSegments(a1, a2, tl, bl)
if (topIntersection.didIntersect) { if (topIntersection.didIntersect) {
intersections.push({ ...topIntersection, message: "top" }) intersections.push({ ...topIntersection, message: 'top' })
} }
if (rightIntersection.didIntersect) { if (rightIntersection.didIntersect) {
intersections.push({ ...rightIntersection, message: "right" }) intersections.push({ ...rightIntersection, message: 'right' })
} }
if (bottomIntersection.didIntersect) { if (bottomIntersection.didIntersect) {
intersections.push({ ...bottomIntersection, message: "bottom" }) intersections.push({ ...bottomIntersection, message: 'bottom' })
} }
if (leftIntersection.didIntersect) { if (leftIntersection.didIntersect) {
intersections.push({ ...leftIntersection, message: "left" }) intersections.push({ ...leftIntersection, message: 'left' })
}
return intersections
}
export function intersectArcRectangle(
start: number[],
end: number[],
center: number[],
radius: number,
point: number[],
size: number[]
) {
const tl = point
const tr = vec.add(point, [size[0], 0])
const br = vec.add(point, size)
const bl = vec.add(point, [0, size[1]])
const intersections: Intersection[] = []
const topIntersection = intersectArcLineSegment(
start,
end,
center,
radius,
tl,
tr
)
const rightIntersection = intersectArcLineSegment(
start,
end,
center,
radius,
tr,
br
)
const bottomIntersection = intersectArcLineSegment(
start,
end,
center,
radius,
bl,
br
)
const leftIntersection = intersectArcLineSegment(
start,
end,
center,
radius,
tl,
bl
)
if (topIntersection.didIntersect) {
intersections.push({ ...topIntersection, message: 'top' })
}
if (rightIntersection.didIntersect) {
intersections.push({ ...rightIntersection, message: 'right' })
}
if (bottomIntersection.didIntersect) {
intersections.push({ ...bottomIntersection, message: 'bottom' })
}
if (leftIntersection.didIntersect) {
intersections.push({ ...leftIntersection, message: 'left' })
} }
return intersections return intersections
@ -360,3 +453,22 @@ export function intersectPolygonBounds(points: number[][], bounds: Bounds) {
return intersections return intersections
} }
export function intersectArcBounds(
start: number[],
end: number[],
center: number[],
radius: number,
bounds: Bounds
) {
const { minX, minY, width, height } = bounds
return intersectArcRectangle(
start,
end,
center,
radius,
[minX, minY],
[width, height]
)
}

View file

@ -1566,3 +1566,18 @@ export function getSvgPathFromStroke(stroke: number[][]) {
d.push('Z') d.push('Z')
return d.join(' ') return d.join(' ')
} }
const PI2 = Math.PI * 2
/**
* Is angle c between angles a and b?
* @param a
* @param b
* @param c
*/
export function isAngleBetween(a: number, b: number, c: number) {
if (c === a || c === b) return true
const AB = (b - a + PI2) % PI2
const AC = (c - a + PI2) % PI2
return AB <= Math.PI !== AC > AB
}

View file

@ -1283,6 +1283,21 @@
"@radix-ui/react-polymorphic" "0.0.11" "@radix-ui/react-polymorphic" "0.0.11"
"@radix-ui/react-primitive" "0.0.13" "@radix-ui/react-primitive" "0.0.13"
"@radix-ui/react-checkbox@^0.0.15":
version "0.0.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-checkbox/-/react-checkbox-0.0.15.tgz#d53b56854fbba65e74ed4486116107638951b9d1"
integrity sha512-R8ErERPlu2kvmqNjxRyyLcS1y3D7J2bQUUEPsvP0BL2AfisUjbT7c9t19k2K/Un3Iieqe93gTPG4LRdbDQQjBw==
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-use-controllable-state" "0.0.6"
"@radix-ui/react-collection@0.0.12": "@radix-ui/react-collection@0.0.12":
version "0.0.12" version "0.0.12"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-0.0.12.tgz#5cd09312cdec34fdbbe1d31affaba69eb768e342" resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-0.0.12.tgz#5cd09312cdec34fdbbe1d31affaba69eb768e342"