diff --git a/components/canvas/bounds/handles.tsx b/components/canvas/bounds/handles.tsx index f59b2e43f..037ee9cf9 100644 --- a/components/canvas/bounds/handles.tsx +++ b/components/canvas/bounds/handles.tsx @@ -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 ( @@ -57,7 +57,7 @@ function Handle({ pointerEvents="all" transform={`translate(${point})`} > - + ) diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 0a080fd32..20162273b 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -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} > {isReady && ( diff --git a/components/canvas/selected.tsx b/components/canvas/selected.tsx index ba7251583..a7955d2b4 100644 --- a/components/canvas/selected.tsx +++ b/components/canvas/selected.tsx @@ -39,7 +39,7 @@ export function ShapeOutline({ id }: { id: string }) { ` return ( - 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 && ( )} - {!shape.isHidden && } + {!shape.isHidden && ( + + )} ) } -const StyledShape = memo( - ({ id, style }: { id: string; style: ShapeStyles }) => { - return - } -) +const RealShape = memo(({ id, style }: { id: string; style: ShapeStyles }) => { + return ( + + ) +}) + +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) diff --git a/components/editor.tsx b/components/editor.tsx index 42cde2330..33a65e30b 100644 --- a/components/editor.tsx +++ b/components/editor.tsx @@ -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', }) diff --git a/components/style-panel/color-picker.tsx b/components/style-panel/color-picker.tsx index d64cec2c0..e9f5dc57e 100644 --- a/components/style-panel/color-picker.tsx +++ b/components/style-panel/color-picker.tsx @@ -14,7 +14,7 @@ export default function ColorPicker({ colors, onChange, children }: Props) { {children} {Object.entries(colors).map(([name, color]) => ( - onChange(color)}> + onChange(name)}> ))} @@ -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', + }, + }, + }, }) diff --git a/components/style-panel/dash-picker.tsx b/components/style-panel/dash-picker.tsx new file mode 100644 index 000000000..af8810a3e --- /dev/null +++ b/components/style-panel/dash-picker.tsx @@ -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) { + state.send('CHANGED_STYLE', { + dash: e.currentTarget.value, + }) +} + +interface Props { + dash: DashStyle +} + +export default function DashPicker({ dash }: Props) { + return ( + + + + + + + + + + + + ) +} + +function DashSolidIcon() { + return ( + + + + ) +} + +function DashDashedIcon() { + return ( + + + + ) +} + +function DashDottedIcon() { + return ( + + + + ) +} diff --git a/components/style-panel/shared.tsx b/components/style-panel/shared.tsx new file mode 100644 index 000000000..9543d3c4f --- /dev/null +++ b/components/style-panel/shared.tsx @@ -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', + }, + }, + }, + }, +}) diff --git a/components/style-panel/style-panel.tsx b/components/style-panel/style-panel.tsx index fc2970125..0098c2fa0 100644 --- a/components/style-panel/style-panel.tsx +++ b/components/style-panel/style-panel.tsx @@ -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(null) @@ -46,14 +47,41 @@ export default function StylePanel() { {isOpen ? ( ) : ( - state.send('TOGGLED_STYLE_PANEL_OPEN')}> - - + <> + + state.send('TOGGLED_STYLE_PANEL_OPEN')} + > + + + )} ) } +function QuickColorSelect({ + prop, + colors, +}: { + prop: ShapeStyles['fill'] | ShapeStyles['stroke'] + colors: Record +}) { + const value = useSelector((s) => s.values.selectedStyle[prop]) + + return ( + state.send('CHANGED_STYLE', { [prop]: color })} + > + + + + + ) +} + // 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 = {} - const overrides = new Set([]) - - 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({}: {}) { - state.send('CHANGED_STYLE', { fill: color })} - > - - - - - state.send('CHANGED_STYLE', { stroke: color })} + onChange={(color) => + state.send('CHANGED_STYLE', { + stroke: strokeColors[color], + fill: getFillColor(color), + }) + } > - + + {/* + + ) => { + console.log(e.target.value) + state.send('CHANGED_STYLE', { + isFilled: e.target.value === 'on', + }) + }} + > + + + */} + + + + ) { +function handleChange(e: ChangeEvent) { state.send('CHANGED_STYLE', { strokeWidth: Number(e.currentTarget.value), }) @@ -16,7 +15,7 @@ export default function WidthPicker({ strokeWidth?: number }) { return ( - + @@ -29,52 +28,3 @@ export default function WidthPicker({ ) } - -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', - }, - }, - }, - }, -}) diff --git a/lib/colors.ts b/lib/colors.ts index 611a238f4..391c74837 100644 --- a/lib/colors.ts +++ b/lib/colors.ts @@ -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)', } diff --git a/lib/shape-utils/arrow.tsx b/lib/shape-utils/arrow.tsx index a99016810..0fcd87b64 100644 --- a/lib/shape-utils/arrow.tsx +++ b/lib/shape-utils/arrow.tsx @@ -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() +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({ boundsCache: new WeakMap([]), @@ -69,7 +84,8 @@ const arrow = registerShapeUtils({ } }, - 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({ ) } - const circle = showCircle && ctpCache.get(handles) + const circle = showCircle && getCtp(shape) return ( @@ -114,12 +130,14 @@ const arrow = registerShapeUtils({ cy={start.point[1]} r={+style.strokeWidth} fill={style.stroke} + strokeDasharray="none" /> ) @@ -127,6 +145,7 @@ const arrow = registerShapeUtils({ applyStyles(shape, style) { Object.assign(shape.style, style) + shape.style.fill = 'none' return this }, @@ -159,24 +178,29 @@ const arrow = registerShapeUtils({ ) } - 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({ 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({ }, 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({ 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)) +} diff --git a/lib/shape-utils/circle.tsx b/lib/shape-utils/circle.tsx index 37501eb99..a4dc0760f 100644 --- a/lib/shape-utils/circle.tsx +++ b/lib/shape-utils/circle.tsx @@ -135,6 +135,7 @@ const circle = registerShapeUtils({ canTransform: true, canChangeAspectRatio: false, + canStyleFill: true, }) export default circle diff --git a/lib/shape-utils/dot.tsx b/lib/shape-utils/dot.tsx index 7f01be34a..eceafa6ef 100644 --- a/lib/shape-utils/dot.tsx +++ b/lib/shape-utils/dot.tsx @@ -104,6 +104,7 @@ const dot = registerShapeUtils({ canTransform: false, canChangeAspectRatio: false, + canStyleFill: true, }) export default dot diff --git a/lib/shape-utils/draw.tsx b/lib/shape-utils/draw.tsx index beb7add32..e46185150 100644 --- a/lib/shape-utils/draw.tsx +++ b/lib/shape-utils/draw.tsx @@ -11,6 +11,7 @@ import { getSvgPathFromStroke, translateBounds, } from 'utils/utils' +import styled from 'styles' const pathCache = new WeakMap([]) @@ -190,6 +191,11 @@ const draw = registerShapeUtils({ canTransform: true, canChangeAspectRatio: true, + canStyleFill: false, }) export default draw + +const DrawPath = styled('path', { + strokeWidth: 0, +}) diff --git a/lib/shape-utils/ellipse.tsx b/lib/shape-utils/ellipse.tsx index 0d1c6421d..43f0fac39 100644 --- a/lib/shape-utils/ellipse.tsx +++ b/lib/shape-utils/ellipse.tsx @@ -149,6 +149,7 @@ const ellipse = registerShapeUtils({ canTransform: true, canChangeAspectRatio: true, + canStyleFill: true, }) export default ellipse diff --git a/lib/shape-utils/index.tsx b/lib/shape-utils/index.tsx index b96d81812..5ab8125ef 100644 --- a/lib/shape-utils/index.tsx +++ b/lib/shape-utils/index.tsx @@ -39,6 +39,9 @@ export interface ShapeUtility> { // 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 diff --git a/lib/shape-utils/line.tsx b/lib/shape-utils/line.tsx index 56a618c9d..a4e34ff48 100644 --- a/lib/shape-utils/line.tsx +++ b/lib/shape-utils/line.tsx @@ -113,6 +113,7 @@ const line = registerShapeUtils({ canTransform: false, canChangeAspectRatio: false, + canStyleFill: false, }) export default line diff --git a/lib/shape-utils/polyline.tsx b/lib/shape-utils/polyline.tsx index 14352e5a3..411d550c6 100644 --- a/lib/shape-utils/polyline.tsx +++ b/lib/shape-utils/polyline.tsx @@ -137,6 +137,7 @@ const polyline = registerShapeUtils({ canTransform: true, canChangeAspectRatio: true, + canStyleFill: false, }) export default polyline diff --git a/lib/shape-utils/ray.tsx b/lib/shape-utils/ray.tsx index 43889b045..e9938bfa0 100644 --- a/lib/shape-utils/ray.tsx +++ b/lib/shape-utils/ray.tsx @@ -112,6 +112,7 @@ const ray = registerShapeUtils({ canTransform: false, canChangeAspectRatio: false, + canStyleFill: false, }) export default ray diff --git a/lib/shape-utils/rectangle.tsx b/lib/shape-utils/rectangle.tsx index bafd5f46e..8e8421686 100644 --- a/lib/shape-utils/rectangle.tsx +++ b/lib/shape-utils/rectangle.tsx @@ -150,6 +150,7 @@ const rectangle = registerShapeUtils({ canTransform: true, canChangeAspectRatio: true, + canStyleFill: true, }) export default rectangle diff --git a/package.json b/package.json index 9bc4aa916..22c8e5665 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/state/inputs.tsx b/state/inputs.tsx index 28bf6ebdb..91ad32f0e 100644 --- a/state/inputs.tsx +++ b/state/inputs.tsx @@ -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 = {} + 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 diff --git a/state/state.ts b/state/state.ts index abc3ca534..84c0a7bd3 100644 --- a/state/state.ts +++ b/state/state.ts @@ -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 = {} + + const overrides = new Set([]) + + 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 + }, }, }) diff --git a/styles/stitches.config.ts b/styles/stitches.config.ts index cb98749c2..c561ed848 100644 --- a/styles/stitches.config.ts +++ b/styles/stitches.config.ts @@ -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)', diff --git a/types.ts b/types.ts index 73a155c2e..d148bac5e 100644 --- a/types.ts +++ b/types.ts @@ -67,7 +67,11 @@ export enum ShapeType { // Cubic = "cubic", // Conic = "conic", -export type ShapeStyles = Partial> +export type ShapeStyles = Partial< + React.SVGProps & { + 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 { diff --git a/utils/intersections.ts b/utils/intersections.ts index 553fcc646..e115095ee 100644 --- a/utils/intersections.ts +++ b/utils/intersections.ts @@ -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] + ) +} diff --git a/utils/utils.ts b/utils/utils.ts index 47959cab1..41c4b1045 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -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 +} diff --git a/yarn.lock b/yarn.lock index 66a52a5be..6517af66a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"