diff --git a/components/canvas/bounds/bounding-box.tsx b/components/canvas/bounds/bounding-box.tsx index c0f5f3bc0..b120d3f35 100644 --- a/components/canvas/bounds/bounding-box.tsx +++ b/components/canvas/bounds/bounding-box.tsx @@ -1,12 +1,18 @@ import * as React from 'react' -import { Edge, Corner } from 'types' +import { Edge, Corner, LineShape, ArrowShape } from 'types' import { useSelector } from 'state' -import { getPage, getSelectedShapes, isMobile } from 'utils/utils' +import { + deepCompareArrays, + getPage, + getSelectedShapes, + isMobile, +} from 'utils/utils' import CenterHandle from './center-handle' import CornerHandle from './corner-handle' import EdgeHandle from './edge-handle' import RotateHandle from './rotate-handle' +import Handles from './handles' export default function Bounds() { const isBrushing = useSelector((s) => s.isIn('brushSelecting')) @@ -14,20 +20,31 @@ export default function Bounds() { const zoom = useSelector((s) => s.data.camera.zoom) const bounds = useSelector((s) => s.values.selectedBounds) + const selectedIds = useSelector( + (s) => Array.from(s.values.selectedIds.values()), + deepCompareArrays + ) + const rotation = useSelector(({ data }) => data.selectedIds.size === 1 ? getSelectedShapes(data)[0].rotation : 0 ) const isAllLocked = useSelector((s) => { const page = getPage(s.data) - return Array.from(s.data.selectedIds.values()).every( - (id) => page.shapes[id].isLocked - ) + return selectedIds.every((id) => page.shapes[id]?.isLocked) + }) + + const isAllHandles = useSelector((s) => { + const page = getPage(s.data) + return selectedIds.every((id) => page.shapes[id]?.handles !== undefined) }) if (!bounds) return null + if (!isSelecting) return null + if (isAllHandles) return null + const size = (isMobile().any ? 10 : 8) / zoom // Touch target size return ( diff --git a/components/canvas/bounds/bounds-bg.tsx b/components/canvas/bounds/bounds-bg.tsx index f4be187dd..e82bf0735 100644 --- a/components/canvas/bounds/bounds-bg.tsx +++ b/components/canvas/bounds/bounds-bg.tsx @@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react' import state, { useSelector } from 'state' import inputs from 'state/inputs' import styled from 'styles' -import { getPage } from 'utils/utils' +import { deepCompareArrays, getPage } from 'utils/utils' function handlePointerDown(e: React.PointerEvent) { if (e.buttons !== 1) return @@ -22,18 +22,34 @@ function handlePointerUp(e: React.PointerEvent) { export default function BoundsBg() { const rBounds = useRef(null) + const bounds = useSelector((state) => state.values.selectedBounds) + const isSelecting = useSelector((s) => s.isIn('selecting')) + + const selectedIds = useSelector( + (s) => Array.from(s.values.selectedIds.values()), + deepCompareArrays + ) + const rotation = useSelector((s) => { - if (s.data.selectedIds.size === 1) { + if (selectedIds.length === 1) { const { shapes } = getPage(s.data) - const selected = Array.from(s.data.selectedIds.values())[0] + const selected = Array.from(s.values.selectedIds.values())[0] return shapes[selected].rotation } else { return 0 } }) + const isAllHandles = useSelector((s) => { + const page = getPage(s.data) + return Array.from(s.values.selectedIds.values()).every( + (id) => page.shapes[id]?.handles !== undefined + ) + }) + + if (isAllHandles) return null if (!bounds) return null if (!isSelecting) return null diff --git a/components/canvas/bounds/handles.tsx b/components/canvas/bounds/handles.tsx new file mode 100644 index 000000000..e92c96068 --- /dev/null +++ b/components/canvas/bounds/handles.tsx @@ -0,0 +1,79 @@ +import useHandleEvents from 'hooks/useHandleEvents' +import { getShapeUtils } from 'lib/shape-utils' +import { useRef } from 'react' +import { useSelector } from 'state' +import styled from 'styles' +import { deepCompareArrays, getPage } from 'utils/utils' +import * as vec from 'utils/vec' + +export default function Handles() { + const selectedIds = useSelector( + (s) => Array.from(s.values.selectedIds.values()), + deepCompareArrays + ) + + const shape = useSelector( + ({ data }) => + selectedIds.length === 1 && getPage(data).shapes[selectedIds[0]] + ) + + const isTranslatingHandles = useSelector((s) => s.isIn('translatingHandles')) + + if (!shape.handles || isTranslatingHandles) return null + + return ( + + {Object.values(shape.handles).map((handle) => ( + + ))} + + ) +} + +function Handle({ + shapeId, + id, + point, +}: { + shapeId: string + id: string + point: number[] +}) { + const rGroup = useRef(null) + const events = useHandleEvents(id, rGroup) + + const transform = ` + translate(${point}) + ` + + return ( + + + + + ) +} + +const HandleCircleOuter = styled('circle', { + fill: 'transparent', + pointerEvents: 'all', + cursor: 'pointer', +}) + +const HandleCircle = styled('circle', { + zStrokeWidth: 2, + stroke: '$text', + fill: '$panel', + pointerEvents: 'none', +}) diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 42ed74a6b..4f7941179 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -10,6 +10,7 @@ import Brush from './brush' import Bounds from './bounds/bounding-box' import BoundsBg from './bounds/bounds-bg' import Selected from './selected' +import Handles from './bounds/handles' export default function Canvas() { const rCanvas = useRef(null) @@ -60,6 +61,7 @@ export default function Canvas() { + )} diff --git a/components/canvas/shape.tsx b/components/canvas/shape.tsx index 0171b4200..9227cd626 100644 --- a/components/canvas/shape.tsx +++ b/components/canvas/shape.tsx @@ -26,7 +26,8 @@ function Shape({ id, isSelecting }: { id: string; isSelecting: boolean }) { const center = getShapeUtils(shape).getCenter(shape) const transform = ` rotate(${shape.rotation * (180 / Math.PI)}, ${center}) - translate(${shape.point})` + translate(${shape.point}) + ` return ( - {text} - - ) -} - const HoverIndicator = styled('path', { fill: 'none', stroke: 'transparent', @@ -133,6 +118,22 @@ const StyledGroup = styled('g', { ], }) +function Label({ text }: { text: string }) { + return ( + + {text} + + ) +} + export { HoverIndicator } export default memo(Shape) diff --git a/components/toolbar.tsx b/components/toolbar.tsx deleted file mode 100644 index eb8e9654e..000000000 --- a/components/toolbar.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import state, { useSelector } from 'state' -import styled from 'styles' -import { Lock, Menu, RotateCcw, RotateCw, Unlock } from 'react-feather' -import { IconButton } from './shared' - -export default function Toolbar() { - const activeTool = useSelector((state) => - state.whenIn({ - selecting: 'select', - dot: 'dot', - circle: 'circle', - ellipse: 'ellipse', - ray: 'ray', - line: 'line', - polyline: 'polyline', - rectangle: 'rectangle', - draw: 'draw', - }) - ) - - const isToolLocked = useSelector((s) => s.data.settings.isToolLocked) - - return ( - -
- - -
-
- - -
-
- ) -} - -const ToolbarContainer = styled('div', { - gridArea: 'toolbar', - userSelect: 'none', - borderBottom: '1px solid black', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - backgroundColor: '$panel', - gap: 8, - fontSize: '$1', - zIndex: 200, -}) - -const Section = styled('div', { - whiteSpace: 'nowrap', - overflowY: 'hidden', - overflowX: 'auto', - display: 'flex', - scrollbarWidth: 'none', - '&::-webkit-scrollbar': { - '-webkit-appearance': 'none', - width: 0, - height: 0, - }, -}) - -const Button = styled('button', { - display: 'flex', - alignItems: 'center', - cursor: 'pointer', - font: '$ui', - fontSize: '$ui', - height: '40px', - outline: 'none', - borderRadius: 0, - border: 'none', - padding: '0 12px', - background: 'none', - '&:hover': { - backgroundColor: '$hint', - }, - '& svg': { - height: 16, - width: 16, - }, - variants: { - isSelected: { - true: { - color: '$selected', - }, - false: {}, - }, - }, -}) diff --git a/components/tools-panel/tools-panel.tsx b/components/tools-panel/tools-panel.tsx index d5d8a7a34..34a0e1da6 100644 --- a/components/tools-panel/tools-panel.tsx +++ b/components/tools-panel/tools-panel.tsx @@ -1,9 +1,9 @@ import { + ArrowTopRightIcon, CircleIcon, CursorArrowIcon, DividerHorizontalIcon, DotIcon, - LineHeightIcon, LockClosedIcon, LockOpen1Icon, Pencil1Icon, @@ -19,29 +19,31 @@ import { ShapeType } from 'types' import UndoRedo from './undo-redo' import Zoom from './zoom' -const selectSelectTool = () => state.send('SELECTED_SELECT_TOOL') -const selectDrawTool = () => state.send('SELECTED_DRAW_TOOL') -const selectDotTool = () => state.send('SELECTED_DOT_TOOL') +const selectArrowTool = () => state.send('SELECTED_ARROW_TOOL') const selectCircleTool = () => state.send('SELECTED_CIRCLE_TOOL') +const selectDotTool = () => state.send('SELECTED_DOT_TOOL') +const selectDrawTool = () => state.send('SELECTED_DRAW_TOOL') const selectEllipseTool = () => state.send('SELECTED_ELLIPSE_TOOL') -const selectRayTool = () => state.send('SELECTED_RAY_TOOL') const selectLineTool = () => state.send('SELECTED_LINE_TOOL') const selectPolylineTool = () => state.send('SELECTED_POLYLINE_TOOL') +const selectRayTool = () => state.send('SELECTED_RAY_TOOL') const selectRectangleTool = () => state.send('SELECTED_RECTANGLE_TOOL') +const selectSelectTool = () => state.send('SELECTED_SELECT_TOOL') const selectToolLock = () => state.send('TOGGLED_TOOL_LOCK') export default function ToolsPanel() { const activeTool = useSelector((state) => state.whenIn({ - selecting: 'select', - dot: ShapeType.Dot, + arrow: ShapeType.Arrow, circle: ShapeType.Circle, + dot: ShapeType.Dot, + draw: ShapeType.Draw, ellipse: ShapeType.Ellipse, - ray: ShapeType.Ray, line: ShapeType.Line, polyline: ShapeType.Polyline, + ray: ShapeType.Ray, rectangle: ShapeType.Rectangle, - draw: ShapeType.Draw, + selecting: 'select', }) ) @@ -83,19 +85,27 @@ export default function ToolsPanel() { - + + {/* + + */} +) { + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (!inputs.canAccept(e.pointerId)) return + e.stopPropagation() + rGroup.current.setPointerCapture(e.pointerId) + state.send('POINTED_HANDLE', inputs.pointerDown(e, id)) + }, + [id] + ) + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + if (!inputs.canAccept(e.pointerId)) return + e.stopPropagation() + rGroup.current.releasePointerCapture(e.pointerId) + state.send('STOPPED_POINTING', inputs.pointerUp(e)) + }, + [id] + ) + + const handlePointerEnter = useCallback( + (e: React.PointerEvent) => { + if (!inputs.canAccept(e.pointerId)) return + state.send('HOVERED_HANDLE', inputs.pointerEnter(e, id)) + }, + [id] + ) + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!inputs.canAccept(e.pointerId)) return + state.send('MOVED_OVER_HANDLE', inputs.pointerEnter(e, id)) + }, + [id] + ) + + const handlePointerLeave = useCallback( + (e: React.PointerEvent) => { + if (!inputs.canAccept(e.pointerId)) return + state.send('UNHOVERED_HANDLE', { target: id }) + }, + [id] + ) + + return { + onPointerDown: handlePointerDown, + onPointerUp: handlePointerUp, + onPointerEnter: handlePointerEnter, + onPointerMove: handlePointerMove, + onPointerLeave: handlePointerLeave, + } +} diff --git a/hooks/useKeyboardEvents.ts b/hooks/useKeyboardEvents.ts index 0c9ba25fd..e4f64b964 100644 --- a/hooks/useKeyboardEvents.ts +++ b/hooks/useKeyboardEvents.ts @@ -128,12 +128,6 @@ export default function useKeyboardEvents() { } break } - case 'a': { - if (metaKey(e)) { - state.send('SELECTED_ALL', getKeyboardEventInfo(e)) - } - break - } case 'v': { if (metaKey(e)) { state.send('PASTED', getKeyboardEventInfo(e)) @@ -142,6 +136,14 @@ export default function useKeyboardEvents() { } break } + case 'a': { + if (metaKey(e)) { + state.send('SELECTED_ALL', getKeyboardEventInfo(e)) + } else { + state.send('SELECTED_ARROW_TOOL', getKeyboardEventInfo(e)) + } + break + } case 'd': { if (metaKey(e)) { state.send('DUPLICATED', getKeyboardEventInfo(e)) diff --git a/lib/shape-utils/arrow.tsx b/lib/shape-utils/arrow.tsx new file mode 100644 index 000000000..54609b653 --- /dev/null +++ b/lib/shape-utils/arrow.tsx @@ -0,0 +1,305 @@ +import { v4 as uuid } from 'uuid' +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 { getBoundsFromPoints, translateBounds } from 'utils/utils' +import { pointInCircle } from 'utils/hitTests' + +const ctpCache = new WeakMap() + +const arrow = registerShapeUtils({ + boundsCache: new WeakMap([]), + + create(props) { + const { + point = [0, 0], + points = [ + [0, 0], + [0, 1], + ], + handles = { + start: { + id: 'start', + index: 0, + point: [0, 0], + }, + end: { + id: 'end', + index: 1, + point: [1, 1], + }, + bend: { + id: 'bend', + index: 2, + point: [0.5, 0.5], + }, + }, + } = props + + return { + id: uuid(), + type: ShapeType.Arrow, + isGenerated: false, + name: 'Arrow', + parentId: 'page0', + childIndex: 0, + point, + rotation: 0, + isAspectRatioLocked: false, + isLocked: false, + isHidden: false, + bend: 0, + points, + handles, + decorations: { + start: null, + end: null, + middle: null, + }, + ...props, + style: { + strokeWidth: 2, + ...props.style, + fill: 'none', + }, + } + }, + + render({ id, bend, points, handles, style }) { + const { start, end, bend: _bend } = handles + + const arrowDist = vec.dist(start.point, end.point) + const bendDist = arrowDist * bend + + const showCircle = Math.abs(bendDist) > 20 + + const v = vec.rot( + vec.mul( + vec.neg(vec.uni(vec.sub(points[1], points[0]))), + Math.min(arrowDist / 2, 16 + +style.strokeWidth * 2) + ), + showCircle ? (bend * Math.PI) / 2 : 0 + ) + const b = vec.add(points[1], vec.rot(v, Math.PI / 6)) + const c = vec.add(points[1], vec.rot(v, -(Math.PI / 6))) + + if (showCircle && !ctpCache.has(handles)) { + ctpCache.set( + handles, + circleFromThreePoints(start.point, end.point, _bend.point) + ) + } + + const circle = showCircle && ctpCache.get(handles) + + return ( + + {circle ? ( + + ) : ( + + )} + + + + ) + }, + + applyStyles(shape, style) { + Object.assign(shape.style, style) + return this + }, + + getBounds(shape) { + if (!this.boundsCache.has(shape)) { + this.boundsCache.set( + shape, + getBoundsFromPoints([ + ...shape.points, + shape.handles['bend'].point, + // vec.sub(shape.handles['bend'].point, shape.point), + ]) + ) + } + + return translateBounds(this.boundsCache.get(shape), shape.point) + }, + + getRotatedBounds(shape) { + return this.getBounds(shape) + }, + + getCenter(shape) { + const bounds = this.getBounds(shape) + return [bounds.minX + bounds.width / 2, bounds.minY + bounds.height / 2] + }, + + hitTest(shape, point) { + const { start, end, bend } = shape.handles + if (shape.bend === 0) { + return ( + vec.distanceToLineSegment( + start.point, + end.point, + vec.sub(point, shape.point) + ) < 4 + ) + } + + if (!ctpCache.has(shape.handles)) { + 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) + }, + + hitTestBounds(this, shape, brushBounds) { + const shapeBounds = this.getBounds(shape) + return ( + boundsContained(shapeBounds, brushBounds) || + intersectCircleBounds(shape.point, 4, brushBounds).length > 0 + ) + }, + + rotateTo(shape, rotation) { + // const rot = rotation - shape.rotation + // const center = this.getCenter(shape) + // shape.points = shape.points.map((pt) => vec.rotWith(pt, shape.point, rot)) + shape.rotation = rotation + return this + }, + + translateTo(shape, point) { + shape.point = vec.toPrecision(point) + return this + }, + + transform(shape, bounds, { initialShape, scaleX, scaleY }) { + const initialShapeBounds = this.getBounds(initialShape) + + shape.point = [bounds.minX, bounds.minY] + + shape.points = shape.points.map((_, i) => { + const [x, y] = initialShape.points[i] + let nw = x / initialShapeBounds.width + let nh = y / initialShapeBounds.height + + if (i === 1) { + let [x0, y0] = initialShape.points[0] + if (x0 === x) nw = 1 + if (y0 === y) nh = 1 + } + + return [ + bounds.width * (scaleX < 0 ? 1 - nw : nw), + bounds.height * (scaleY < 0 ? 1 - nh : nh), + ] + }) + + return this + }, + + transformSingle(shape, bounds, info) { + this.transform(shape, bounds, info) + return this + }, + + setProperty(shape, prop, value) { + shape[prop] = value + return this + }, + + onHandleMove(shape, handles) { + for (let id in handles) { + const handle = handles[id] + + shape.handles[handle.id] = handle + + if (handle.index < 2) { + shape.points[handle.index] = handle.point + } + + const { start, end, bend } = shape.handles + + const midPoint = vec.med(start.point, end.point) + 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) + if (!vec.clockwise(start.point, bend.point, end.point)) shape.bend *= -1 + } + + const bendDist = (dist / 2) * shape.bend + 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 + } + + return this + }, + + canTransform: true, + canChangeAspectRatio: true, +}) + +export default arrow + +function getArrowArcPath( + cx: number, + cy: number, + r: number, + start: number[], + end: number[] +) { + return ` + A ${r},${r},0, + ${getSweep([cx, cy], start, end) > 0 ? '1' : '0'}, + 0,${end[0]},${end[1]}` +} diff --git a/lib/shape-utils/index.tsx b/lib/shape-utils/index.tsx index e607990e4..b96d81812 100644 --- a/lib/shape-utils/index.tsx +++ b/lib/shape-utils/index.tsx @@ -1,14 +1,12 @@ import { Bounds, - BoundsSnapshot, Shape, - Shapes, ShapeType, Corner, Edge, - ShapeByType, ShapeStyles, - PropsOfType, + ShapeHandle, + ShapeBinding, } from 'types' import circle from './circle' import dot from './dot' @@ -18,6 +16,7 @@ import ellipse from './ellipse' import line from './line' import ray from './ray' import draw from './draw' +import arrow from './arrow' /* Shape Utiliies @@ -90,6 +89,20 @@ export interface ShapeUtility> { value: K[P] ): ShapeUtility + // Respond when a user moves one of the shape's bound elements. + onBindingMove?( + this: ShapeUtility, + shape: K, + bindings: Record + ): ShapeUtility + + // Respond when a user moves one of the shape's handles. + onHandleMove?( + this: ShapeUtility, + shape: K, + handle: Partial + ): ShapeUtility + // Render a shape to JSX. render(this: ShapeUtility, shape: K): JSX.Element @@ -119,6 +132,7 @@ const shapeUtilityMap: Record> = { [ShapeType.Line]: line, [ShapeType.Ray]: ray, [ShapeType.Draw]: draw, + [ShapeType.Arrow]: arrow, } /** diff --git a/lib/shape-utils/polyline.tsx b/lib/shape-utils/polyline.tsx index b577256b1..14352e5a3 100644 --- a/lib/shape-utils/polyline.tsx +++ b/lib/shape-utils/polyline.tsx @@ -43,8 +43,7 @@ const polyline = registerShapeUtils({ getBounds(shape) { if (!this.boundsCache.has(shape)) { - const bounds = getBoundsFromPoints(shape.points) - this.boundsCache.set(shape, bounds) + this.boundsCache.set(shape, getBoundsFromPoints(shape.points)) } return translateBounds(this.boundsCache.get(shape), shape.point) @@ -106,6 +105,7 @@ const polyline = registerShapeUtils({ transform(shape, bounds, { initialShape, scaleX, scaleY }) { const initialShapeBounds = this.getBounds(initialShape) + shape.points = shape.points.map((_, i) => { const [x, y] = initialShape.points[i] diff --git a/state/commands/arrow.ts b/state/commands/arrow.ts new file mode 100644 index 000000000..ddee738eb --- /dev/null +++ b/state/commands/arrow.ts @@ -0,0 +1,43 @@ +import Command from './command' +import history from '../history' +import { ArrowShape, Data } from 'types' +import { getPage } from 'utils/utils' +import { ArrowSnapshot } from 'state/sessions/arrow-session' +import { getShapeUtils } from 'lib/shape-utils' + +export default function arrowCommand( + data: Data, + before: ArrowSnapshot, + after: ArrowSnapshot +) { + history.execute( + data, + new Command({ + name: 'point_arrow', + category: 'canvas', + manualSelection: true, + do(data, isInitial) { + if (isInitial) return + + const { initialShape, currentPageId } = after + + getPage(data, currentPageId).shapes[initialShape.id] = initialShape + + data.selectedIds.clear() + data.selectedIds.add(initialShape.id) + data.hoveredId = undefined + data.pointedId = undefined + }, + undo(data) { + const { initialShape, currentPageId } = before + const shapes = getPage(data, currentPageId).shapes + + delete shapes[initialShape.id] + + data.selectedIds.clear() + data.hoveredId = undefined + data.pointedId = undefined + }, + }) + ) +} diff --git a/state/commands/handle.ts b/state/commands/handle.ts new file mode 100644 index 000000000..950b7acd2 --- /dev/null +++ b/state/commands/handle.ts @@ -0,0 +1,36 @@ +import Command from './command' +import history from '../history' +import { Data } from 'types' +import { getPage } from 'utils/utils' +import { HandleSnapshot } from 'state/sessions/handle-session' +import { getShapeUtils } from 'lib/shape-utils' + +export default function handleCommand( + data: Data, + before: HandleSnapshot, + after: HandleSnapshot +) { + history.execute( + data, + new Command({ + name: 'moved_handle', + category: 'canvas', + do(data, isInitial) { + if (isInitial) return + + const { initialShape, currentPageId } = after + + const shape = getPage(data, currentPageId).shapes[initialShape.id] + + getShapeUtils(shape).onHandleMove(shape, initialShape.handles) + }, + undo(data) { + const { initialShape, currentPageId } = before + + const shape = getPage(data, currentPageId).shapes[initialShape.id] + + getShapeUtils(shape).onHandleMove(shape, initialShape.handles) + }, + }) + ) +} diff --git a/state/commands/index.ts b/state/commands/index.ts index ddb7d0e85..eea864a24 100644 --- a/state/commands/index.ts +++ b/state/commands/index.ts @@ -1,39 +1,43 @@ import align from './align' +import arrow from './arrow' import deleteSelected from './delete-selected' import direct from './direct' import distribute from './distribute' +import draw from './draw' import duplicate from './duplicate' import generate from './generate' import move from './move' -import draw from './draw' +import nudge from './nudge' import rotate from './rotate' +import rotateCcw from './rotate-ccw' import stretch from './stretch' import style from './style' +import toggle from './toggle' import transform from './transform' import transformSingle from './transform-single' import translate from './translate' -import nudge from './nudge' -import toggle from './toggle' -import rotateCcw from './rotate-ccw' +import handle from './handle' const commands = { align, + arrow, deleteSelected, direct, distribute, + draw, duplicate, generate, move, - draw, + nudge, rotate, + rotateCcw, stretch, style, + toggle, transform, transformSingle, translate, - nudge, - toggle, - rotateCcw, + handle, } export default commands diff --git a/state/commands/transform-single.ts b/state/commands/transform-single.ts index 198f30ee6..10a8033ae 100644 --- a/state/commands/transform-single.ts +++ b/state/commands/transform-single.ts @@ -23,7 +23,7 @@ export default function transformSingleCommand( category: 'canvas', manualSelection: true, do(data) { - const { id, type, initialShape, initialShapeBounds } = after + const { id, type, initialShapeBounds } = after const { shapes } = getPage(data, after.currentPageId) @@ -35,7 +35,7 @@ export default function transformSingleCommand( } else { getShapeUtils(shape).transformSingle(shape, initialShapeBounds, { type, - initialShape, + initialShape: before.initialShape, scaleX, scaleY, transformOrigin: [0.5, 0.5], diff --git a/state/commands/transform.ts b/state/commands/transform.ts index d188c2739..f25fcf2f0 100644 --- a/state/commands/transform.ts +++ b/state/commands/transform.ts @@ -8,7 +8,9 @@ import { getPage } from 'utils/utils' export default function transformCommand( data: Data, before: TransformSnapshot, - after: TransformSnapshot + after: TransformSnapshot, + scaleX: number, + scaleY: number ) { history.execute( data, @@ -29,8 +31,8 @@ export default function transformCommand( type, initialShape, transformOrigin, - scaleX: 1, - scaleY: 1, + scaleX, + scaleY, }) } }, @@ -48,8 +50,8 @@ export default function transformCommand( type, initialShape, transformOrigin, - scaleX: 1, - scaleY: 1, + scaleX, + scaleY, }) } }, diff --git a/state/data.ts b/state/data.ts index 72295a283..06e369460 100644 --- a/state/data.ts +++ b/state/data.ts @@ -10,107 +10,124 @@ export const defaultDocument: Data['document'] = { name: 'Page 0', childIndex: 0, shapes: { - shape3: shapeUtils[ShapeType.Dot].create({ - id: 'shape3', - name: 'Shape 3', - childIndex: 3, - point: [400, 500], - style: { - stroke: shades.black, - fill: shades.lightGray, - strokeWidth: 1, - }, - }), - shape0: shapeUtils[ShapeType.Circle].create({ - id: 'shape0', - name: 'Shape 0', - childIndex: 1, - point: [100, 600], - radius: 50, - style: { - stroke: shades.black, - fill: shades.lightGray, - strokeWidth: 1, - }, - }), - shape5: shapeUtils[ShapeType.Ellipse].create({ - id: 'shape5', - name: 'Shape 5', - childIndex: 5, + arrowShape0: shapeUtils[ShapeType.Arrow].create({ + id: 'arrowShape0', point: [200, 200], - radiusX: 50, - radiusY: 100, - style: { - stroke: shades.black, - fill: shades.lightGray, - strokeWidth: 1, - }, - }), - shape7: shapeUtils[ShapeType.Ellipse].create({ - id: 'shape7', - name: 'Shape 7', - childIndex: 7, - point: [100, 100], - radiusX: 50, - radiusY: 30, - style: { - stroke: shades.black, - fill: shades.lightGray, - strokeWidth: 1, - }, - }), - shape6: shapeUtils[ShapeType.Line].create({ - id: 'shape6', - name: 'Shape 6', - childIndex: 1, - point: [400, 400], - direction: [0.2, 0.2], - style: { - stroke: shades.black, - fill: shades.lightGray, - strokeWidth: 1, - }, - }), - rayShape: shapeUtils[ShapeType.Ray].create({ - id: 'rayShape', - name: 'Ray', - childIndex: 3, - point: [300, 100], - direction: [0.5, 0.5], - style: { - stroke: shades.black, - fill: shades.lightGray, - strokeWidth: 1, - }, - }), - shape2: shapeUtils[ShapeType.Polyline].create({ - id: 'shape2', - name: 'Shape 2', - childIndex: 2, - point: [200, 600], points: [ [0, 0], - [75, 200], - [100, 50], + [200, 200], ], - style: { - stroke: shades.black, - fill: shades.none, - strokeWidth: 1, - }, }), - shape1: shapeUtils[ShapeType.Rectangle].create({ - id: 'shape1', - name: 'Shape 1', - childIndex: 1, - point: [400, 600], - size: [200, 200], - style: { - stroke: shades.black, - fill: shades.lightGray, - strokeWidth: 1, - }, + arrowShape1: shapeUtils[ShapeType.Arrow].create({ + id: 'arrowShape1', + point: [100, 100], + points: [ + [0, 0], + [300, 0], + ], }), + + // shape3: shapeUtils[ShapeType.Dot].create({ + // id: 'shape3', + // name: 'Shape 3', + // childIndex: 3, + // point: [400, 500], + // style: { + // stroke: shades.black, + // fill: shades.lightGray, + // strokeWidth: 1, + // }, + // }), + // shape0: shapeUtils[ShapeType.Circle].create({ + // id: 'shape0', + // name: 'Shape 0', + // childIndex: 1, + // point: [100, 600], + // radius: 50, + // style: { + // stroke: shades.black, + // fill: shades.lightGray, + // strokeWidth: 1, + // }, + // }), + // shape5: shapeUtils[ShapeType.Ellipse].create({ + // id: 'shape5', + // name: 'Shape 5', + // childIndex: 5, + // point: [200, 200], + // radiusX: 50, + // radiusY: 100, + // style: { + // stroke: shades.black, + // fill: shades.lightGray, + // strokeWidth: 1, + // }, + // }), + // shape7: shapeUtils[ShapeType.Ellipse].create({ + // id: 'shape7', + // name: 'Shape 7', + // childIndex: 7, + // point: [100, 100], + // radiusX: 50, + // radiusY: 30, + // style: { + // stroke: shades.black, + // fill: shades.lightGray, + // strokeWidth: 1, + // }, + // }), + // shape6: shapeUtils[ShapeType.Line].create({ + // id: 'shape6', + // name: 'Shape 6', + // childIndex: 1, + // point: [400, 400], + // direction: [0.2, 0.2], + // style: { + // stroke: shades.black, + // fill: shades.lightGray, + // strokeWidth: 1, + // }, + // }), + // rayShape: shapeUtils[ShapeType.Ray].create({ + // id: 'rayShape', + // name: 'Ray', + // childIndex: 3, + // point: [300, 100], + // direction: [0.5, 0.5], + // style: { + // stroke: shades.black, + // fill: shades.lightGray, + // strokeWidth: 1, + // }, + // }), + // shape2: shapeUtils[ShapeType.Polyline].create({ + // id: 'shape2', + // name: 'Shape 2', + // childIndex: 2, + // point: [200, 600], + // points: [ + // [0, 0], + // [75, 200], + // [100, 50], + // ], + // style: { + // stroke: shades.black, + // fill: shades.none, + // strokeWidth: 1, + // }, + // }), + // shape1: shapeUtils[ShapeType.Rectangle].create({ + // id: 'shape1', + // name: 'Shape 1', + // childIndex: 1, + // point: [400, 600], + // size: [200, 200], + // style: { + // stroke: shades.black, + // fill: shades.lightGray, + // strokeWidth: 1, + // }, + // }), }, }, }, diff --git a/state/sessions/arrow-session.ts b/state/sessions/arrow-session.ts new file mode 100644 index 000000000..2d3ad5744 --- /dev/null +++ b/state/sessions/arrow-session.ts @@ -0,0 +1,92 @@ +import { ArrowShape, Data, LineShape, RayShape } from 'types' +import * as vec from 'utils/vec' +import BaseSession from './base-session' +import commands from 'state/commands' +import { current } from 'immer' +import { getPage } from 'utils/utils' +import { getShapeUtils } from 'lib/shape-utils' + +export default class PointsSession extends BaseSession { + points: number[][] + origin: number[] + snapshot: ArrowSnapshot + isLocked: boolean + lockedDirection: 'horizontal' | 'vertical' + + constructor(data: Data, id: string, point: number[], isLocked: boolean) { + super(data) + this.origin = point + this.points = [[0, 0]] + this.snapshot = getArrowSnapshot(data, id) + } + + update(data: Data, point: number[], isLocked = false) { + const { id } = this.snapshot + + const delta = vec.vec(this.origin, point) + + if (isLocked) { + if (!this.isLocked && this.points.length > 1) { + this.isLocked = true + + if (Math.abs(delta[0]) < Math.abs(delta[1])) { + this.lockedDirection = 'vertical' + } else { + this.lockedDirection = 'horizontal' + } + } + } else { + if (this.isLocked) { + this.isLocked = false + } + } + + if (this.isLocked) { + if (this.lockedDirection === 'vertical') { + point[0] = this.origin[0] + } else { + point[1] = this.origin[1] + } + } + + const shape = getPage(data).shapes[id] as ArrowShape + + getShapeUtils(shape).onHandleMove(shape, { + end: { + ...shape.handles.end, + point: vec.sub(point, shape.point), + }, + }) + } + + cancel(data: Data) { + const { id, initialShape } = this.snapshot + + const shape = getPage(data).shapes[id] as ArrowShape + + getShapeUtils(shape) + .onHandleMove(shape, { end: initialShape.handles.end }) + .translateTo(shape, initialShape.point) + } + + complete(data: Data) { + commands.arrow( + data, + this.snapshot, + getArrowSnapshot(data, this.snapshot.id) + ) + } +} + +export function getArrowSnapshot(data: Data, id: string) { + const initialShape = getPage(current(data)).shapes[id] as ArrowShape + + return { + id, + initialShape, + selectedIds: new Set(data.selectedIds), + currentPageId: data.currentPageId, + } +} + +export type ArrowSnapshot = ReturnType diff --git a/state/sessions/draw-session.ts b/state/sessions/draw-session.ts index 71cf049c7..b03f61f44 100644 --- a/state/sessions/draw-session.ts +++ b/state/sessions/draw-session.ts @@ -23,11 +23,6 @@ export default class BrushSession extends BaseSession { this.points = [] this.snapshot = getDrawSnapshot(data, id) - // if (isLocked && prevEndPoint) { - // const continuedPt = vec.sub([...prevEndPoint], this.origin) - // this.points.push(continuedPt) - // } - const page = getPage(data) const shape = page.shapes[id] getShapeUtils(shape).translateTo(shape, point) diff --git a/state/sessions/handle-session.ts b/state/sessions/handle-session.ts new file mode 100644 index 000000000..c7a5902ae --- /dev/null +++ b/state/sessions/handle-session.ts @@ -0,0 +1,76 @@ +import { Data } from 'types' +import * as vec from 'utils/vec' +import BaseSession from './base-session' +import commands from 'state/commands' +import { current } from 'immer' +import { getPage } from 'utils/utils' +import { getShapeUtils } from 'lib/shape-utils' + +export default class HandleSession extends BaseSession { + delta = [0, 0] + origin: number[] + snapshot: HandleSnapshot + + constructor(data: Data, shapeId: string, handleId: string, point: number[]) { + super(data) + this.origin = point + this.snapshot = getHandleSnapshot(data, shapeId, handleId) + } + + update(data: Data, point: number[], isAligned: boolean) { + const { currentPageId, handleId, initialShape } = this.snapshot + const shape = getPage(data, currentPageId).shapes[initialShape.id] + + const delta = vec.vec(this.origin, point) + + if (isAligned) { + if (Math.abs(delta[0]) < Math.abs(delta[1])) { + delta[0] = 0 + } else { + delta[1] = 0 + } + } + + const handles = initialShape.handles + + getShapeUtils(shape).onHandleMove(shape, { + [handleId]: { + ...handles[handleId], + point: vec.add(handles[handleId].point, delta), + }, + }) + } + + cancel(data: Data) { + const { currentPageId, handleId, initialShape } = this.snapshot + const shape = getPage(data, currentPageId).shapes[initialShape.id] + } + + complete(data: Data) { + commands.handle( + data, + this.snapshot, + getHandleSnapshot( + data, + this.snapshot.initialShape.id, + this.snapshot.handleId + ) + ) + } +} + +export function getHandleSnapshot( + data: Data, + shapeId: string, + handleId: string +) { + const initialShape = getPage(current(data)).shapes[shapeId] + + return { + currentPageId: data.currentPageId, + handleId, + initialShape, + } +} + +export type HandleSnapshot = ReturnType diff --git a/state/sessions/index.ts b/state/sessions/index.ts index da65aa21c..b94c2627e 100644 --- a/state/sessions/index.ts +++ b/state/sessions/index.ts @@ -1,13 +1,16 @@ -import BaseSession from "./base-session" -import BrushSession from "./brush-session" -import DirectionSession from "./direction-session" -import DrawSession from "./draw-session" -import RotateSession from "./rotate-session" -import TransformSession from "./transform-session" -import TransformSingleSession from "./transform-single-session" -import TranslateSession from "./translate-session" +import ArrowSession from './arrow-session' +import BaseSession from './base-session' +import BrushSession from './brush-session' +import DirectionSession from './direction-session' +import DrawSession from './draw-session' +import RotateSession from './rotate-session' +import TransformSession from './transform-session' +import TransformSingleSession from './transform-single-session' +import TranslateSession from './translate-session' +import HandleSession from './handle-session' export { + ArrowSession, BaseSession, BrushSession, DirectionSession, @@ -16,4 +19,5 @@ export { TransformSession, TransformSingleSession, TranslateSession, + HandleSession, } diff --git a/state/sessions/transform-session.ts b/state/sessions/transform-session.ts index 01d1bce72..ea607c648 100644 --- a/state/sessions/transform-session.ts +++ b/state/sessions/transform-session.ts @@ -100,7 +100,9 @@ export default class TransformSession extends BaseSession { commands.transform( data, this.snapshot, - getTransformSnapshot(data, this.transformType) + getTransformSnapshot(data, this.transformType), + this.scaleX, + this.scaleY ) } } diff --git a/state/state.ts b/state/state.ts index 289239dfa..bc245c7bc 100644 --- a/state/state.ts +++ b/state/state.ts @@ -118,6 +118,7 @@ const state = createState({ }, SELECTED_SELECT_TOOL: { to: 'selecting' }, SELECTED_DRAW_TOOL: { unless: 'isReadOnly', to: 'draw' }, + SELECTED_ARROW_TOOL: { unless: 'isReadOnly', to: 'arrow' }, SELECTED_DOT_TOOL: { unless: 'isReadOnly', to: 'dot' }, SELECTED_CIRCLE_TOOL: { unless: 'isReadOnly', to: 'circle' }, SELECTED_ELLIPSE_TOOL: { unless: 'isReadOnly', to: 'ellipse' }, @@ -168,6 +169,7 @@ const state = createState({ to: 'rotatingSelection', else: { to: 'transformingSelection' }, }, + POINTED_HANDLE: { to: 'translatingHandles' }, MOVED_OVER_SHAPE: { if: 'pointHitsShape', then: { @@ -216,7 +218,7 @@ const state = createState({ MOVED_POINTER: { unless: 'isReadOnly', if: 'distanceImpliesDrag', - to: 'draggingSelection', + to: 'translatingSelection', }, }, }, @@ -243,7 +245,7 @@ const state = createState({ CANCELLED: { do: 'cancelSession', to: 'selecting' }, }, }, - draggingSelection: { + translatingSelection: { onEnter: 'startTranslateSession', on: { MOVED_POINTER: 'updateTranslateSession', @@ -256,6 +258,17 @@ const state = createState({ CANCELLED: { do: 'cancelSession', to: 'selecting' }, }, }, + translatingHandles: { + onEnter: 'startHandleSession', + on: { + MOVED_POINTER: 'updateHandleSession', + PANNED_CAMERA: 'updateHandleSession', + PRESSED_SHIFT_KEY: 'keyUpdateHandleSession', + RELEASED_SHIFT_KEY: 'keyUpdateHandleSession', + STOPPED_POINTING: { do: 'completeSession', to: 'selecting' }, + CANCELLED: { do: 'cancelSession', to: 'selecting' }, + }, + }, brushSelecting: { onEnter: [ { @@ -399,6 +412,51 @@ const state = createState({ }, }, }, + arrow: { + initial: 'creating', + states: { + creating: { + on: { + CANCELLED: { to: 'selecting' }, + POINTED_SHAPE: { + get: 'newArrow', + do: 'createShape', + to: 'arrow.editing', + }, + POINTED_CANVAS: { + get: 'newArrow', + do: 'createShape', + to: 'arrow.editing', + }, + UNDO: { do: 'undo' }, + REDO: { do: 'redo' }, + }, + }, + editing: { + onEnter: 'startArrowSession', + on: { + STOPPED_POINTING: [ + 'completeSession', + { + if: 'isToolLocked', + to: 'arrow.creating', + else: { to: 'selecting' }, + }, + ], + CANCELLED: { + do: 'breakSession', + if: 'isToolLocked', + to: 'arrow.creating', + else: { to: 'selecting' }, + }, + PRESSED_SHIFT: 'keyUpdateArrowSession', + RELEASED_SHIFT: 'keyUpdateArrowSession', + MOVED_POINTER: 'updateArrowSession', + PANNED_CAMERA: 'updateArrowSession', + }, + }, + }, + }, circle: { initial: 'creating', states: { @@ -586,6 +644,9 @@ const state = createState({ }, }, results: { + newArrow() { + return ShapeType.Arrow + }, newDraw() { return ShapeType.Draw }, @@ -749,7 +810,39 @@ const state = createState({ ) }, - // Dragging / Translating + // Dragging Handle + startHandleSession(data, payload: PointerInfo) { + const shapeId = Array.from(data.selectedIds.values())[0] + const handleId = payload.target + + session = new Sessions.HandleSession( + data, + shapeId, + handleId, + screenToWorld(inputs.pointer.origin, data) + ) + }, + keyUpdateHandleSession( + data, + payload: { shiftKey: boolean; altKey: boolean } + ) { + session.update( + data, + screenToWorld(inputs.pointer.point, data), + payload.shiftKey, + payload.altKey + ) + }, + updateHandleSession(data, payload: PointerInfo) { + session.update( + data, + screenToWorld(payload.point, data), + payload.shiftKey, + payload.altKey + ) + }, + + // Transforming startTransformSession( data, payload: PointerInfo & { target: Corner | Edge } @@ -817,6 +910,27 @@ const state = createState({ session.update(data, screenToWorld(payload.point, data), payload.shiftKey) }, + // Arrow + startArrowSession(data, payload: PointerInfo) { + const id = Array.from(data.selectedIds.values())[0] + session = new Sessions.ArrowSession( + data, + id, + screenToWorld(inputs.pointer.origin, data), + payload.shiftKey + ) + }, + keyUpdateArrowSession(data, payload: PointerInfo) { + session.update( + data, + screenToWorld(inputs.pointer.point, data), + payload.shiftKey + ) + }, + updateArrowSession(data, payload: PointerInfo) { + session.update(data, screenToWorld(payload.point, data), payload.shiftKey) + }, + // Nudges nudgeSelection(data, payload: { delta: number[]; shiftKey: boolean }) { commands.nudge( diff --git a/types.ts b/types.ts index f7e5d3698..73a155c2e 100644 --- a/types.ts +++ b/types.ts @@ -58,6 +58,7 @@ export enum ShapeType { Polyline = 'polyline', Rectangle = 'rectangle', Draw = 'draw', + Arrow = 'arrow', } // Consider: @@ -77,6 +78,8 @@ export interface BaseShape { name: string point: number[] rotation: number + bindings?: Record + handles?: Record style: ShapeStyles isLocked: boolean isHidden: boolean @@ -124,6 +127,18 @@ export interface DrawShape extends BaseShape { points: number[][] } +export interface ArrowShape extends BaseShape { + type: ShapeType.Arrow + points: number[][] + handles: Record + bend: number + decorations?: { + start: Decoration + end: Decoration + middle: Decoration + } +} + export type MutableShape = | DotShape | CircleShape @@ -133,6 +148,7 @@ export type MutableShape = | PolylineShape | DrawShape | RectangleShape + | ArrowShape export type Shape = Readonly @@ -145,6 +161,7 @@ export interface Shapes { [ShapeType.Polyline]: Readonly [ShapeType.Draw]: Readonly [ShapeType.Rectangle]: Readonly + [ShapeType.Arrow]: Readonly } export type ShapeByType = Shapes[T] @@ -155,6 +172,22 @@ export interface CodeFile { code: string } +export enum Decoration { + Arrow, +} + +export interface ShapeBinding { + id: string + index: number + point: number[] +} + +export interface ShapeHandle { + id: string + index: number + point: number[] +} + /* -------------------------------------------------- */ /* Editor UI */ /* -------------------------------------------------- */ diff --git a/utils/svg.ts b/utils/svg.ts index 438e24d38..f9ee530ab 100644 --- a/utils/svg.ts +++ b/utils/svg.ts @@ -1,7 +1,7 @@ // Some helpers for drawing SVGs. -import * as vec from "./vec" -import { getSweep } from "utils/utils" +import * as vec from './vec' +import { getSweep } from 'utils/utils' // General @@ -37,24 +37,24 @@ export function bezierTo(A: number[], B: number[], C: number[]) { export function arcTo(C: number[], r: number, A: number[], B: number[]) { return [ - // moveTo(A), - "A", + moveTo(A), + 'A', r, r, 0, - getSweep(C, A, B) > 0 ? "1" : "0", + getSweep(C, A, B) > 0 ? '1' : '0', 0, B[0], B[1], - ].join(" ") + ].join(' ') } export function closePath() { - return "Z" + return 'Z' } export function rectTo(A: number[]) { - return ["R", A[0], A[1]].join(" ") + return ['R', A[0], A[1]].join(' ') } export function getPointAtLength(path: SVGPathElement, length: number) { diff --git a/utils/utils.ts b/utils/utils.ts index f6501538d..47959cab1 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -750,7 +750,7 @@ export function det( * @param p0 * @param p1 * @param center - * @returns + * @returns [x, y, r] */ export function circleFromThreePoints(A: number[], B: number[], C: number[]) { const a = det(A[0], A[1], 1, B[0], B[1], 1, C[0], C[1], 1) @@ -788,11 +788,12 @@ export function circleFromThreePoints(A: number[], B: number[], C: number[]) { C[0], C[1] ) - return [ - -bx / (2 * a), - -by / (2 * a), - Math.sqrt(bx * bx + by * by - 4 * a * c) / (2 * Math.abs(a)), - ] + + const x = -bx / (2 * a) + const y = -by / (2 * a) + const r = Math.sqrt(bx * bx + by * by - 4 * a * c) / (2 * Math.abs(a)) + + return [x, y, r] } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -993,7 +994,6 @@ export function getBoundsFromPoints(points: number[][], rotation = 0): Bounds { } if (rotation !== 0) { - console.log('returning rotated bounds') return getBoundsFromPoints( points.map((pt) => vec.rotWith(pt, [(minX + maxX) / 2, (minY + maxY) / 2], rotation) @@ -1304,8 +1304,8 @@ export function getTransformedBoundingBox( maxY: by1, width: bx1 - bx0, height: by1 - by0, - scaleX: ((bx1 - bx0) / (ax1 - ax0)) * (flipX ? -1 : 1), - scaleY: ((by1 - by0) / (ay1 - ay0)) * (flipY ? -1 : 1), + scaleX: ((bx1 - bx0) / (ax1 - ax0 || 1)) * (flipX ? -1 : 1), + scaleY: ((by1 - by0) / (ay1 - ay0 || 1)) * (flipY ? -1 : 1), } }