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]]
)
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 (
<g>
@ -57,7 +57,7 @@ function Handle({
pointerEvents="all"
transform={`translate(${point})`}
>
<HandleCircleOuter r={8} />
<HandleCircleOuter r={12} />
<DotCircle r={4} />
</g>
)

View file

@ -34,10 +34,20 @@ export default function Canvas() {
} else {
if (isMobile()) {
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) => {
if (!inputs.canAccept(e.pointerId)) return
if (inputs.canAccept(e.pointerId)) {
@ -58,6 +68,7 @@ export default function Canvas() {
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onTouchStart={handleTouchStart}
// onTouchMove={handleTouchMove}
>
<Defs />
{isReady && (

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@ export default function ColorPicker({ colors, onChange, children }: Props) {
{children}
<Colors sideOffset={4}>
{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} />
</ColorButton>
))}
@ -29,7 +29,7 @@ export function ColorIcon({ color }: { color: string }) {
)
}
const Colors = styled(DropdownMenu.Content, {
export const Colors = styled(DropdownMenu.Content, {
display: 'grid',
padding: 4,
gridTemplateColumns: 'repeat(6, 1fr)',
@ -117,4 +117,13 @@ export const CurrentColor = styled(DropdownMenu.Trigger, {
strokeWidth: 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 { useRef } from 'react'
import { IconButton } from 'components/shared'
import { Circle, Copy, Lock, Trash, Trash2, Unlock, X } from 'react-feather'
import {
deepCompare,
deepCompareArrays,
getPage,
getSelectedShapes,
} from 'utils/utils'
import * as Checkbox from '@radix-ui/react-checkbox'
import { Trash2, X } from 'react-feather'
import { deepCompare, deepCompareArrays, getPage } from 'utils/utils'
import { shades, fills, strokes } from 'lib/colors'
import ColorPicker, { ColorIcon, CurrentColor } from './color-picker'
import AlignDistribute from './align-distribute'
import { MoveType, ShapeStyles } from 'types'
import WidthPicker from './width-picker'
import {
AlignTopIcon,
ArrowDownIcon,
ArrowUpIcon,
AspectRatioIcon,
BoxIcon,
CheckIcon,
CopyIcon,
DotsHorizontalIcon,
DotsVerticalIcon,
EyeClosedIcon,
EyeOpenIcon,
LockClosedIcon,
@ -31,11 +26,17 @@ import {
PinBottomIcon,
PinTopIcon,
RotateCounterClockwiseIcon,
TrashIcon,
} from '@radix-ui/react-icons'
import DashPicker from './dash-picker'
const fillColors = { ...shades, ...fills }
const strokeColors = { ...shades, ...strokes }
const getFillColor = (color: string) => {
if (shades[color]) {
return '#fff'
}
return fillColors[color]
}
export default function StylePanel() {
const rContainer = useRef<HTMLDivElement>(null)
@ -46,14 +47,41 @@ export default function StylePanel() {
{isOpen ? (
<SelectedShapeStyles />
) : (
<IconButton onClick={() => state.send('TOGGLED_STYLE_PANEL_OPEN')}>
<DotsHorizontalIcon />
</IconButton>
<>
<QuickColorSelect prop="stroke" colors={strokeColors} />
<IconButton
title="Style"
onClick={() => state.send('TOGGLED_STYLE_PANEL_OPEN')}
>
<DotsVerticalIcon />
</IconButton>
</>
)}
</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
// information, based on the user's current selection. We might have to keep
// track of this data manually within our state.
@ -79,33 +107,7 @@ function SelectedShapeStyles({}: {}) {
return selectedIds.every((id) => page.shapes[id].isHidden)
})
const commonStyle = useSelector((s) => {
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 commonStyle = useSelector((s) => s.values.selectedStyle, deepCompare)
const hasSelection = selectedIds.length > 0
@ -118,28 +120,42 @@ function SelectedShapeStyles({}: {}) {
</IconButton>
</Panel.Header>
<Content>
<ColorPicker
colors={fillColors}
onChange={(color) => state.send('CHANGED_STYLE', { fill: color })}
>
<CurrentColor>
<label>Fill</label>
<ColorIcon color={commonStyle.fill} />
</CurrentColor>
</ColorPicker>
<ColorPicker
colors={strokeColors}
onChange={(color) => state.send('CHANGED_STYLE', { stroke: color })}
onChange={(color) =>
state.send('CHANGED_STYLE', {
stroke: strokeColors[color],
fill: getFillColor(color),
})
}
>
<CurrentColor>
<label>Stroke</label>
<label>Color</label>
<ColorIcon color={commonStyle.stroke} />
</CurrentColor>
</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>
<label htmlFor="width">Width</label>
<WidthPicker strokeWidth={Number(commonStyle.strokeWidth)} />
</Row>
<Row>
<label htmlFor="dash">Dash</label>
<DashPicker dash={commonStyle.dash} />
</Row>
<ButtonsRow>
<IconButton
disabled={!hasSelection}
@ -221,14 +237,16 @@ const StylePanelRoot = styled(Panel.Root, {
position: 'relative',
border: '1px solid $panel',
boxShadow: '0px 2px 4px rgba(0,0,0,.12)',
display: 'flex',
alignItems: 'center',
pointerEvents: 'all',
variants: {
isOpen: {
true: {},
false: {
padding: 2,
height: 38,
width: 38,
width: 'fit-content',
},
},
},
@ -275,3 +293,22 @@ const ButtonsRow = styled('div', {
justifyContent: 'flex-start',
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 { Circle } from 'react-feather'
import state from 'state'
import styled from 'styles'
function setWidth(e: ChangeEvent<HTMLInputElement>) {
function handleChange(e: ChangeEvent<HTMLInputElement>) {
state.send('CHANGED_STYLE', {
strokeWidth: Number(e.currentTarget.value),
})
@ -16,7 +15,7 @@ export default function WidthPicker({
strokeWidth?: number
}) {
return (
<Group name="width" onValueChange={setWidth}>
<Group name="width" onValueChange={handleChange}>
<RadioItem value="2" isActive={strokeWidth === 2}>
<Circle size={6} />
</RadioItem>
@ -29,52 +28,3 @@ export default function WidthPicker({
</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 = {
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(243, 252, 227, 1.000)',
green: 'rgba(235, 251, 238, 1.000)',
teal: 'rgba(230, 252, 245, 1.000)',
cyan: 'rgba(227, 250, 251, 1.000)',
blue: 'rgba(231, 245, 255, 1.000)',
indigo: 'rgba(237, 242, 255, 1.000)',
violet: 'rgba(242, 240, 255, 1.000)',
grape: 'rgba(249, 240, 252, 1.000)',
pink: 'rgba(254, 241, 246, 1.000)',
red: 'rgba(255, 245, 245, 1.000)',
orange: 'rgba(255, 244, 229, 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 { ArrowShape, ShapeHandle, ShapeType } from 'types'
import { registerShapeUtils } from './index'
import { circleFromThreePoints, clamp, getSweep } from 'utils/utils'
import { boundsContained } from 'utils/bounds'
import { intersectCircleBounds } from 'utils/intersections'
import { circleFromThreePoints, clamp, isAngleBetween } from 'utils/utils'
import { pointInBounds } from 'utils/bounds'
import {
intersectArcBounds,
intersectLineSegmentBounds,
} from 'utils/intersections'
import { getBoundsFromPoints, translateBounds } from 'utils/utils'
import { pointInCircle } from 'utils/hitTests'
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>({
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 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 (
<g id={id}>
@ -114,12 +130,14 @@ const arrow = registerShapeUtils<ArrowShape>({
cy={start.point[1]}
r={+style.strokeWidth}
fill={style.stroke}
strokeDasharray="none"
/>
<polyline
points={[b, points[1], c].join()}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
strokeDasharray="none"
/>
</g>
)
@ -127,6 +145,7 @@ const arrow = registerShapeUtils<ArrowShape>({
applyStyles(shape, style) {
Object.assign(shape.style, style)
shape.style.fill = 'none'
return this
},
@ -159,24 +178,29 @@ const arrow = registerShapeUtils<ArrowShape>({
)
}
if (!ctpCache.has(shape.handles)) {
ctpCache.set(
shape.handles,
circleFromThreePoints(start.point, end.point, bend.point)
)
}
const [cx, cy, r] = ctpCache.get(shape.handles)
const [cx, cy, r] = getCtp(shape)
return !pointInCircle(point, vec.add(shape.point, [cx, cy]), r - 4)
},
hitTestBounds(this, shape, brushBounds) {
const shapeBounds = this.getBounds(shape)
return (
boundsContained(shapeBounds, brushBounds) ||
intersectCircleBounds(shape.point, 4, brushBounds).length > 0
)
const { start, end, bend } = shape.handles
const sp = vec.add(shape.point, start.point)
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) {
@ -219,14 +243,7 @@ const arrow = registerShapeUtils<ArrowShape>({
start.point = shape.points[0]
end.point = shape.points[1]
const bendDist = (vec.dist(start.point, end.point) / 2) * shape.bend
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
bend.point = getBendPoint(shape)
shape.points = [shape.handles.start.point, shape.handles.end.point]
@ -244,8 +261,6 @@ const arrow = registerShapeUtils<ArrowShape>({
},
onHandleMove(shape, handles) {
const { start, end, bend } = shape.handles
for (let id in handles) {
const handle = handles[id]
@ -255,38 +270,35 @@ const arrow = registerShapeUtils<ArrowShape>({
shape.points[handle.index] = handle.point
}
const { start, end, bend } = shape.handles
const dist = vec.dist(start.point, end.point)
if (handle.id === 'bend') {
const distance = vec.distanceToLineSegment(
start.point,
end.point,
handle.point,
true
)
shape.bend = clamp(distance / (dist / 2), -1, 1)
const midPoint = vec.med(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))
const a0 = vec.angle(handle.point, end.point)
const a1 = vec.angle(start.point, end.point)
if (a0 - a1 < 0) shape.bend *= -1
bend.point = vec.nearestPointOnLineSegment(ap, bp, bend.point, true)
shape.bend = vec.dist(bend.point, midPoint) / (dist / 2)
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
}
}
}
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))
shape.handles.bend.point =
Math.abs(bendDist) > 10
? vec.add(midPoint, vec.mul(vec.per(u), bendDist))
: midPoint
shape.handles.bend.point = getBendPoint(shape)
return this
},
canTransform: true,
canChangeAspectRatio: true,
canStyleFill: false,
})
export default arrow
@ -311,3 +323,16 @@ function getArrowArcPath(
end.point[1],
].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,
canChangeAspectRatio: false,
canStyleFill: true,
})
export default circle

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,6 +9,7 @@
},
"dependencies": {
"@monaco-editor/react": "^4.1.3",
"@radix-ui/react-checkbox": "^0.0.15",
"@radix-ui/react-dropdown-menu": "^0.0.19",
"@radix-ui/react-icons": "^1.0.3",
"@radix-ui/react-radio-group": "^0.0.16",

View file

@ -1,3 +1,4 @@
import React from 'react'
import { PointerInfo } from 'types'
import { isDarwin } from 'utils/utils'
@ -5,6 +6,52 @@ class Inputs {
activePointerId?: number
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) {
const { shiftKey, ctrlKey, metaKey, altKey } = e

View file

@ -15,6 +15,7 @@ import {
getCurrent,
getPage,
getSelectedBounds,
getSelectedShapes,
getShape,
screenToWorld,
setZoomCSS,
@ -32,6 +33,7 @@ import {
DistributeType,
AlignType,
StretchType,
DashStyle,
} from 'types'
const initialData: Data = {
@ -50,6 +52,7 @@ const initialData: Data = {
fill: shades.lightGray,
stroke: shades.darkGray,
strokeWidth: 2,
dash: DashStyle.Solid,
},
camera: {
point: [0, 0],
@ -1296,6 +1299,35 @@ const state = createState({
...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: {
brushFill: 'rgba(0,0,0,.1)',
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)',
bounds: 'rgba(65, 132, 244, 1.000)',
boundsBg: 'rgba(65, 132, 244, 0.100)',

View file

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

View file

@ -1,5 +1,6 @@
import { Bounds } from "types"
import * as vec from "utils/vec"
import { Bounds } from 'types'
import * as vec from 'utils/vec'
import { isAngleBetween } from './utils'
interface Intersection {
didIntersect: boolean
@ -26,22 +27,22 @@ export function intersectLineSegments(
const u_b = BV[1] * AV[0] - BV[0] * AV[1]
if (ua_t === 0 || ub_t === 0) {
return getIntersection("coincident")
return getIntersection('coincident')
}
if (u_b === 0) {
return getIntersection("parallel")
return getIntersection('parallel')
}
if (u_b != 0) {
const ua = ua_t / u_b
const ub = ub_t / u_b
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(
@ -65,11 +66,11 @@ export function intersectCircleLineSegment(
const deter = b * b - 4 * a * cc
if (deter < 0) {
return getIntersection("outside")
return getIntersection('outside')
}
if (deter === 0) {
return getIntersection("tangent")
return getIntersection('tangent')
}
var e = Math.sqrt(deter)
@ -77,9 +78,9 @@ export function intersectCircleLineSegment(
var u2 = (-b - e) / (2 * a)
if ((u1 < 0 || u1 > 1) && (u2 < 0 || u2 > 1)) {
if ((u1 < 0 && u2 < 0) || (u1 > 1 && u2 > 1)) {
return getIntersection("outside")
return getIntersection('outside')
} 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 <= u2 && u2 <= 1) results.push(vec.lrp(a1, a2, u2))
return getIntersection("intersection", ...results)
return getIntersection('intersection', ...results)
}
export function intersectEllipseLineSegment(
@ -100,7 +101,7 @@ export function intersectEllipseLineSegment(
) {
// If the ellipse or line segment are empty, return no tValues.
if (rx === 0 || ry === 0 || vec.isEqual(a1, a2)) {
return getIntersection("No intersection")
return getIntersection('No intersection')
}
// 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((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(
@ -163,19 +189,19 @@ export function intersectCircleRectangle(
const leftIntersection = intersectCircleLineSegment(c, r, tl, bl)
if (topIntersection.didIntersect) {
intersections.push({ ...topIntersection, message: "top" })
intersections.push({ ...topIntersection, message: 'top' })
}
if (rightIntersection.didIntersect) {
intersections.push({ ...rightIntersection, message: "right" })
intersections.push({ ...rightIntersection, message: 'right' })
}
if (bottomIntersection.didIntersect) {
intersections.push({ ...bottomIntersection, message: "bottom" })
intersections.push({ ...bottomIntersection, message: 'bottom' })
}
if (leftIntersection.didIntersect) {
intersections.push({ ...leftIntersection, message: "left" })
intersections.push({ ...leftIntersection, message: 'left' })
}
return intersections
@ -230,19 +256,19 @@ export function intersectEllipseRectangle(
)
if (topIntersection.didIntersect) {
intersections.push({ ...topIntersection, message: "top" })
intersections.push({ ...topIntersection, message: 'top' })
}
if (rightIntersection.didIntersect) {
intersections.push({ ...rightIntersection, message: "right" })
intersections.push({ ...rightIntersection, message: 'right' })
}
if (bottomIntersection.didIntersect) {
intersections.push({ ...bottomIntersection, message: "bottom" })
intersections.push({ ...bottomIntersection, message: 'bottom' })
}
if (leftIntersection.didIntersect) {
intersections.push({ ...leftIntersection, message: "left" })
intersections.push({ ...leftIntersection, message: 'left' })
}
return intersections
@ -267,19 +293,86 @@ export function intersectRectangleLineSegment(
const leftIntersection = intersectLineSegments(a1, a2, tl, bl)
if (topIntersection.didIntersect) {
intersections.push({ ...topIntersection, message: "top" })
intersections.push({ ...topIntersection, message: 'top' })
}
if (rightIntersection.didIntersect) {
intersections.push({ ...rightIntersection, message: "right" })
intersections.push({ ...rightIntersection, message: 'right' })
}
if (bottomIntersection.didIntersect) {
intersections.push({ ...bottomIntersection, message: "bottom" })
intersections.push({ ...bottomIntersection, message: 'bottom' })
}
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
@ -360,3 +453,22 @@ export function intersectPolygonBounds(points: number[][], bounds: Bounds) {
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')
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-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":
version "0.0.12"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-0.0.12.tgz#5cd09312cdec34fdbbe1d31affaba69eb768e342"