From b2360550d9a3e9d93958ca1e6c7404dcf8b650da Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 9 Jul 2021 17:15:27 +0100 Subject: [PATCH 1/8] Refactors shapes --- components/canvas/defs.tsx | 28 ++--- components/canvas/page.tsx | 208 +++++++++++++++++++++++++++++--- components/canvas/shape.tsx | 107 +++++++--------- hooks/usePageShapes.ts | 26 ++-- state/shape-utils/arrow.tsx | 16 ++- state/shape-utils/draw.tsx | 34 ++++-- state/shape-utils/ellipse.tsx | 34 +++--- state/shape-utils/line.tsx | 4 +- state/shape-utils/polyline.tsx | 3 +- state/shape-utils/ray.tsx | 4 +- state/shape-utils/rectangle.tsx | 5 +- state/shape-utils/text.tsx | 44 ++++--- state/state.ts | 43 ++++--- types.ts | 7 +- utils/utils.ts | 37 ++++++ 15 files changed, 419 insertions(+), 181 deletions(-) diff --git a/components/canvas/defs.tsx b/components/canvas/defs.tsx index bb4ce574b..111bc197e 100644 --- a/components/canvas/defs.tsx +++ b/components/canvas/defs.tsx @@ -1,47 +1,39 @@ -import { getShapeUtils } from 'state/shape-utils' import React from 'react' import { useSelector } from 'state' import tld from 'utils/tld' import { DotCircle, Handle } from './misc' -import useShape from 'hooks/useShape' -import useShapesToRender from 'hooks/useShapesToRender' import styled from 'styles' export default function Defs(): JSX.Element { - const shapeIdsToRender = useShapesToRender() - return ( - - {shapeIdsToRender.map((id) => ( - - ))} + ) } -function Def({ id }: { id: string }) { - const shape = useShape(id) - if (!shape) return null - return getShapeUtils(shape).render(shape, { isEditing: false }) -} - function ExpandDef() { const zoom = useSelector((s) => tld.getCurrentCamera(s.data).zoom) return ( - + ) } -function ShadowDef() { +function HoverDef() { return ( - + ) } diff --git a/components/canvas/page.tsx b/components/canvas/page.tsx index 4aab83fab..3874aec32 100644 --- a/components/canvas/page.tsx +++ b/components/canvas/page.tsx @@ -1,7 +1,10 @@ import { useSelector } from 'state' -import Shape from './shape' -import HoveredShape from './hovered-shape' -import usePageShapes from 'hooks/usePageShapes' +import tld from 'utils/tld' +import useShapeEvents from 'hooks/useShapeEvents' +import { Data, Shape, ShapeType, TextShape } from 'types' +import { getShapeUtils } from 'state/shape-utils' +import { boundsCollide, boundsContain, shallowEqual } from 'utils' +import { memo, useRef } from 'react' /* On each state change, compare node ids of all shapes @@ -10,24 +13,197 @@ here; and still cheaper than any other pattern I've found. */ export default function Page(): JSX.Element { - const showHovers = useSelector((s) => - s.isInAny('selecting', 'text', 'editingShape') - ) + // Get the shapes that fit into the current window + const shapeTree = useSelector((s) => { + const allowHovers = s.isInAny('selecting', 'text', 'editingShape') - const visiblePageShapeIds = usePageShapes() + const viewport = tld.getViewport(s.data) - const hoveredShapeId = useSelector((s) => { - return visiblePageShapeIds.find((id) => id === s.data.hoveredId) + const shapesToShow = s.values.currentShapes.filter((shape) => { + if (shape.type === ShapeType.Ray || shape.type === ShapeType.Line) { + return true + } + + const shapeBounds = getShapeUtils(shape).getBounds(shape) + + return ( + boundsContain(viewport, shapeBounds) || + boundsCollide(viewport, shapeBounds) + ) + }) + + const tree: Node[] = [] + + shapesToShow.forEach((shape) => + addToTree(s.data, s.values.selectedIds, allowHovers, tree, shape) + ) + + return tree }) return ( - - {showHovers && hoveredShapeId && ( - - )} - {visiblePageShapeIds.map((id) => ( - + <> + {shapeTree.map((node) => ( + ))} - + ) } + +type Node = { + shape: Shape + children: Node[] + isEditing: boolean + isHovered: boolean + isSelected: boolean + isCurrentParent: boolean +} + +function addToTree( + data: Data, + selectedIds: string[], + allowHovers: boolean, + branch: Node[], + shape: Shape +): void { + const node = { + shape, + children: [], + isHovered: data.hoveredId === shape.id, + isCurrentParent: data.currentParentId === shape.id, + isEditing: data.editingId === shape.id, + isSelected: selectedIds.includes(shape.id), + } + + branch.push(node) + + if (shape.children) { + shape.children + .map((id) => tld.getShape(data, id)) + .sort((a, b) => a.childIndex - b.childIndex) + .forEach((shape) => { + addToTree(data, selectedIds, allowHovers, node.children, shape) + }) + } +} + +const ShapeNode = ({ + node: { shape, children, isEditing, isHovered, isSelected, isCurrentParent }, +}: { + node: Node + parentPoint?: number[] +}) => { + return ( + <> + + {children.map((childNode) => ( + + ))} + + ) +} + +interface TranslatedShapeProps { + shape: Shape + isEditing: boolean + isHovered: boolean + isSelected: boolean + isCurrentParent: boolean +} + +const TranslatedShape = memo( + ({ + shape, + isEditing, + isHovered, + isSelected, + isCurrentParent, + }: TranslatedShapeProps) => { + const rGroup = useRef(null) + const events = useShapeEvents(shape.id, isCurrentParent, rGroup) + + const center = getShapeUtils(shape).getCenter(shape) + const rotation = shape.rotation * (180 / Math.PI) + + const transform = ` + rotate(${rotation}, ${center}) + translate(${shape.point}) + ` + + return ( + + {isEditing && shape.type === ShapeType.Text ? ( + + ) : ( + + )} + + ) + }, + shallowEqual +) + +interface RenderedShapeProps { + shape: Shape + isEditing: boolean + isHovered: boolean + isSelected: boolean + isCurrentParent: boolean +} + +const RenderedShape = memo( + function RenderedShape({ + shape, + isEditing, + isHovered, + isSelected, + isCurrentParent, + }: RenderedShapeProps) { + return getShapeUtils(shape).render(shape, { + isEditing, + isHovered, + isSelected, + isCurrentParent, + }) + }, + (prev, next) => { + if ( + prev.isEditing !== next.isEditing || + prev.isHovered !== next.isHovered || + prev.isSelected !== next.isSelected || + prev.isCurrentParent !== next.isCurrentParent + ) { + return false + } + + if (next.shape !== prev.shape) { + return !getShapeUtils(next.shape).shouldRender(next.shape, prev.shape) + } + + return true + } +) + +function EditingTextShape({ shape }: { shape: TextShape }) { + const ref = useRef(null) + + return getShapeUtils(shape).render(shape, { + ref, + isEditing: true, + isHovered: false, + isSelected: false, + isCurrentParent: false, + }) +} diff --git a/components/canvas/shape.tsx b/components/canvas/shape.tsx index 7c3ded9d8..2b78f77ec 100644 --- a/components/canvas/shape.tsx +++ b/components/canvas/shape.tsx @@ -8,59 +8,33 @@ import useShapeEvents from 'hooks/useShapeEvents' import useShape from 'hooks/useShape' import vec from 'utils/vec' import { getShapeStyle } from 'state/shape-styles' +import { Shape as _Shape } from 'types' interface ShapeProps { - id: string + shape: _Shape + parent?: _Shape } -function Shape({ id }: ShapeProps): JSX.Element { +function Shape({ shape, parent }: ShapeProps): JSX.Element { const rGroup = useRef(null) - const isHidden = useSelector((s) => { - const shape = tld.getShape(s.data, id) - if (shape === undefined) return true - return shape?.isHidden - }) + const { id, isHidden, children } = shape + const style = getShapeStyle(shape.style) + const { strokeWidth } = style - const children = useSelector((s) => { - const shape = tld.getShape(s.data, id) - if (shape === undefined) return [] - return shape?.children - }) + const center = getShapeUtils(shape).getCenter(shape) + const rotation = shape.rotation * (180 / Math.PI) + const parentPoint = parent?.point || [0, 0] - const strokeWidth = useSelector((s) => { - const shape = tld.getShape(s.data, id) - if (shape === undefined) return 0 - const style = getShapeStyle(shape?.style) - return +style.strokeWidth - }) - - const transform = useSelector((s) => { - const shape = tld.getShape(s.data, id) - if (shape === undefined) return '' - const center = getShapeUtils(shape).getCenter(shape) - const rotation = shape.rotation * (180 / Math.PI) - const parentPoint = tld.getShape(s.data, shape.parentId)?.point || [0, 0] - - return ` + const transform = ` translate(${vec.neg(parentPoint)}) rotate(${rotation}, ${center}) translate(${shape.point}) ` - }) - const isCurrentParent = useSelector((s) => { - return s.data.currentParentId === id - }) + const isCurrentParent = false - const events = useShapeEvents(id, isCurrentParent, rGroup) - - const shape = tld.getShape(state.data, id) - - if (!shape) { - console.warn('Could not find that shape:', id) - return null - } + const events = useShapeEvents(shape.id, isCurrentParent, rGroup) // From here on, not reactive—if we're here, we can trust that the // shape in state is a shape with changes that we need to render. @@ -90,37 +64,54 @@ function Shape({ id }: ShapeProps): JSX.Element { (isForeignObject ? ( ) : ( - + ))} {isParent && - children.map((shapeId) => )} + children.map((shapeId) => ( + + ))} ) } export default memo(Shape) +// function Def({ id }: { id: string }) { +// const shape = useShape(id) +// if (!shape) return null +// return getShapeUtils(shape).render(shape, { isEditing: false }) +// } + interface RealShapeProps { id: string isParent: boolean strokeWidth: number + shape: _Shape } -const RealShape = memo(function RealShape({ - id, - isParent, - strokeWidth, -}: RealShapeProps) { - return ( - - ) -}) +const RealShape = memo( + function RealShape({ shape }: RealShapeProps) { + return getShapeUtils(shape).render(shape, { isEditing: false }) + }, + (prev, next) => { + return ( + prev.shape && + next.shape && + next.shape !== prev.shape && + getShapeUtils(next.shape).shouldRender(next.shape, prev.shape) + ) + } +) const ForeignObjectHover = memo(function ForeignObjectHover({ id, @@ -172,12 +163,6 @@ const ForeignObjectRender = memo(function ForeignObjectRender({ return getShapeUtils(shape).render(shape, { isEditing, ref: rFocusable }) }) -const StyledShape = styled('path', { - strokeLinecap: 'round', - strokeLinejoin: 'round', - pointerEvents: 'none', -}) - const EventSoak = styled('use', { opacity: 0, strokeLinecap: 'round', diff --git a/hooks/usePageShapes.ts b/hooks/usePageShapes.ts index bfd438745..6b074da88 100644 --- a/hooks/usePageShapes.ts +++ b/hooks/usePageShapes.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import state, { useSelector } from 'state' import { getShapeUtils } from 'state/shape-utils' -import { PageState, Bounds, ShapeType } from 'types' +import { PageState, Bounds, ShapeType, Shape } from 'types' import { boundsCollide, boundsContain, @@ -12,7 +12,7 @@ import tld from 'utils/tld' const viewportCache = new WeakMap() -export default function usePageShapes(): string[] { +export default function usePageShapes(): Shape[] { // Reset the viewport cache when the window resizes useEffect(() => { const handleResize = debounce(() => state.send('RESIZED_WINDOW'), 32) @@ -35,19 +35,17 @@ export default function usePageShapes(): string[] { const viewport = viewportCache.get(pageState) - const shapesToShow = s.values.currentShapes - .filter((shape) => { - if (shape.type === ShapeType.Ray || shape.type === ShapeType.Line) { - return true - } + const shapesToShow = s.values.currentShapes.filter((shape) => { + if (shape.type === ShapeType.Ray || shape.type === ShapeType.Line) { + return true + } - const shapeBounds = getShapeUtils(shape).getBounds(shape) - return ( - boundsContain(viewport, shapeBounds) || - boundsCollide(viewport, shapeBounds) - ) - }) - .map((shape) => shape.id) + const shapeBounds = getShapeUtils(shape).getBounds(shape) + return ( + boundsContain(viewport, shapeBounds) || + boundsCollide(viewport, shapeBounds) + ) + }) return shapesToShow }, deepCompareArrays) diff --git a/state/shape-utils/arrow.tsx b/state/shape-utils/arrow.tsx index 57be51a89..5b0f5d443 100644 --- a/state/shape-utils/arrow.tsx +++ b/state/shape-utils/arrow.tsx @@ -102,7 +102,7 @@ const arrow = registerShapeUtils({ return shape.handles !== prev.handles || shape.style !== prev.style }, - render(shape) { + render(shape, { isHovered }) { const { id, bend, handles, style } = shape const { start, end, bend: _bend } = handles @@ -215,22 +215,26 @@ const arrow = registerShapeUtils({ strokeDasharray={strokeDasharray} strokeDashoffset={strokeDashoffset} strokeLinecap="round" - > + /> ) } + const sw = strokeWidth * 1.618 + return ( - + {shaftPath} {shape.decorations.start === Decoration.Arrow && ( )} {shape.decorations.end === Decoration.Arrow && ( @@ -238,9 +242,11 @@ const arrow = registerShapeUtils({ d={getArrowHeadPath(shape, end.point, insetEnd)} fill="none" stroke={styles.stroke} - strokeWidth={strokeWidth * 1.618} + strokeWidth={sw} strokeDashoffset="none" strokeDasharray="none" + strokeLinecap="round" + strokeLinejoin="round" /> )} diff --git a/state/shape-utils/draw.tsx b/state/shape-utils/draw.tsx index 37f0cec0a..238f6e19f 100644 --- a/state/shape-utils/draw.tsx +++ b/state/shape-utils/draw.tsx @@ -40,7 +40,7 @@ const draw = registerShapeUtils({ return shape.points !== prev.points || shape.style !== prev.style }, - render(shape) { + render(shape, { isHovered }) { const { id, points, style } = shape const styles = getShapeStyle(style) @@ -54,15 +54,17 @@ const draw = registerShapeUtils({ // For very short lines, draw a point instead of a line if (points.length > 0 && points.length < 3) { + const sw = strokeWidth * 0.618 + return ( - - - + ) } @@ -85,6 +87,8 @@ const draw = registerShapeUtils({ strokeWidth="0" stroke="none" fill={styles.fill} + strokeLinejoin="round" + strokeLinecap="round" /> )} ({ fill={styles.stroke} stroke={styles.stroke} strokeWidth={strokeWidth} + strokeLinejoin="round" + strokeLinecap="round" + filter={isHovered ? 'url(#expand)' : 'none'} /> ) @@ -119,6 +126,8 @@ const draw = registerShapeUtils({ const path = simplePathCache.get(points) + const sw = strokeWidth * 1.618 + return ( {style.dash !== DashStyle.Solid && ( @@ -127,15 +136,20 @@ const draw = registerShapeUtils({ fill="transparent" stroke="transparent" strokeWidth={strokeWidth * 2} + strokeLinejoin="round" + strokeLinecap="round" /> )} ) diff --git a/state/shape-utils/ellipse.tsx b/state/shape-utils/ellipse.tsx index 8bfe6847a..d812a2148 100644 --- a/state/shape-utils/ellipse.tsx +++ b/state/shape-utils/ellipse.tsx @@ -42,7 +42,7 @@ const ellipse = registerShapeUtils({ ) }, - render(shape) { + render(shape, { isHovered }) { const { id, radiusX, radiusY, style } = shape const styles = getShapeStyle(style) const strokeWidth = +styles.strokeWidth @@ -74,7 +74,8 @@ const ellipse = registerShapeUtils({ d={path} fill={styles.stroke} stroke={styles.stroke} - strokeWidth={styles.strokeWidth} + strokeWidth={strokeWidth} + filter={isHovered ? 'url(#expand)' : 'none'} /> ) @@ -92,21 +93,22 @@ const ellipse = registerShapeUtils({ 4 ) + const sw = strokeWidth * 1.618 + return ( - - - + ) }, diff --git a/state/shape-utils/line.tsx b/state/shape-utils/line.tsx index 5e2a517fc..d904089a6 100644 --- a/state/shape-utils/line.tsx +++ b/state/shape-utils/line.tsx @@ -26,7 +26,7 @@ const line = registerShapeUtils({ return shape.direction !== prev.direction || shape.style !== prev.style }, - render(shape) { + render(shape, { isHovered }) { const { id, direction } = shape const [x1, y1] = vec.add([0, 0], vec.mul(direction, 10000)) const [x2, y2] = vec.sub([0, 0], vec.mul(direction, 10000)) @@ -34,7 +34,7 @@ const line = registerShapeUtils({ const styles = getShapeStyle(shape.style) return ( - + diff --git a/state/shape-utils/polyline.tsx b/state/shape-utils/polyline.tsx index 4c0172f00..133a84b87 100644 --- a/state/shape-utils/polyline.tsx +++ b/state/shape-utils/polyline.tsx @@ -28,7 +28,7 @@ const polyline = registerShapeUtils({ shouldRender(shape, prev) { return shape.points !== prev.points || shape.style !== prev.style }, - render(shape) { + render(shape, { isHovered }) { const { id, points } = shape const styles = getShapeStyle(shape.style) @@ -40,6 +40,7 @@ const polyline = registerShapeUtils({ stroke={styles.stroke} strokeWidth={styles.strokeWidth} fill={shape.style.isFilled ? styles.fill : 'none'} + filter={isHovered ? 'url(#expand)' : 'none'} /> ) }, diff --git a/state/shape-utils/ray.tsx b/state/shape-utils/ray.tsx index 4f4178452..67836b843 100644 --- a/state/shape-utils/ray.tsx +++ b/state/shape-utils/ray.tsx @@ -25,7 +25,7 @@ const ray = registerShapeUtils({ shouldRender(shape, prev) { return shape.direction !== prev.direction || shape.style !== prev.style }, - render(shape) { + render(shape, { isHovered }) { const { id, direction } = shape const styles = getShapeStyle(shape.style) @@ -33,7 +33,7 @@ const ray = registerShapeUtils({ const [x2, y2] = vec.add([0, 0], vec.mul(direction, 10000)) return ( - + diff --git a/state/shape-utils/rectangle.tsx b/state/shape-utils/rectangle.tsx index ce0f9b763..2508479ff 100644 --- a/state/shape-utils/rectangle.tsx +++ b/state/shape-utils/rectangle.tsx @@ -28,7 +28,7 @@ const rectangle = registerShapeUtils({ return shape.size !== prev.size || shape.style !== prev.style }, - render(shape) { + render(shape, { isHovered }) { const { id, size, radius, style } = shape const styles = getShapeStyle(style) const strokeWidth = +styles.strokeWidth @@ -56,6 +56,7 @@ const rectangle = registerShapeUtils({ fill={styles.stroke} stroke={styles.stroke} strokeWidth={styles.strokeWidth} + filter={isHovered ? 'url(#expand)' : 'none'} /> ) @@ -106,7 +107,7 @@ const rectangle = registerShapeUtils({ fill={styles.fill} stroke="none" /> - {paths} + {paths} ) }, diff --git a/state/shape-utils/text.tsx b/state/shape-utils/text.tsx index 1c195fc8f..9bcec55e5 100644 --- a/state/shape-utils/text.tsx +++ b/state/shape-utils/text.tsx @@ -1,4 +1,4 @@ -import { uniqueId, isMobile, getFromCache } from 'utils/utils' +import { uniqueId, getFromCache } from 'utils/utils' import vec from 'utils/vec' import TextAreaUtils from 'utils/text-area' import { TextShape, ShapeType } from 'types' @@ -70,7 +70,7 @@ const text = registerShapeUtils({ ) }, - render(shape, { isEditing, ref }) { + render(shape, { isEditing, isHovered, ref }) { const { id, text, style } = shape const styles = getShapeStyle(style) const font = getFontStyle(shape.scale, shape.style) @@ -106,31 +106,36 @@ const text = registerShapeUtils({ } } - function handleBlur() { + function handleBlur(e: React.FocusEvent) { + if (isEditing) { + e.currentTarget.focus() + e.currentTarget.select() + return + } + setTimeout(() => state.send('BLURRED_EDITING_SHAPE', { id }), 0) } - function handleFocus(e: React.FocusEvent) { - e.currentTarget.select() + function handleFocus() { state.send('FOCUSED_EDITING_SHAPE', { id }) } - function handlePointerDown(e: React.PointerEvent) { - if (e.currentTarget.selectionEnd !== 0) { - e.currentTarget.selectionEnd = 0 + function handlePointerDown() { + if (ref.current.selectionEnd !== 0) { + ref.current.selectionEnd = 0 } } const fontSize = getFontSize(shape.style.size) * shape.scale const lineHeight = fontSize * 1.4 - if (ref === undefined) { - throw Error('This component should receive a ref.') - } - if (!isEditing) { return ( - + {text.split('\n').map((str, i) => ( ({ y={4 + fontSize / 2 + i * lineHeight} fontFamily="Verveine Regular" fontStyle="normal" - fontWeight="regular" + fontWeight="500" fontSize={fontSize} width={bounds.width} height={bounds.height} fill={styles.stroke} color={styles.stroke} - stroke={styles.stroke} + stroke="none" xmlSpace="preserve" dominantBaseline="mathematical" alignmentBaseline="mathematical" @@ -155,12 +160,18 @@ const text = registerShapeUtils({ ) } + + if (ref === undefined) { + throw Error('This component should receive a ref when editing.') + } + return ( e.stopPropagation()} > } @@ -177,7 +188,7 @@ const text = registerShapeUtils({ autoSave="false" placeholder="" color={styles.stroke} - autoFocus={!!isMobile()} + autoFocus={true} onFocus={handleFocus} onBlur={handleBlur} onKeyDown={handleKeyDown} @@ -287,6 +298,7 @@ const StyledTextArea = styled('textarea', { minWidth: 1, lineHeight: 1.4, outline: 0, + fontWeight: '500', backgroundColor: '$boundsBg', overflow: 'hidden', pointerEvents: 'all', diff --git a/state/state.ts b/state/state.ts index 86ed7659d..01ffed47b 100644 --- a/state/state.ts +++ b/state/state.ts @@ -663,6 +663,21 @@ const state = createState({ onExit: ['completeSession', 'clearEditingId'], on: { EDITED_SHAPE: { do: 'updateEditSession' }, + POINTED_SHAPE: [ + { + unless: 'isPointingEditingShape', + if: 'isPointingTextShape', + do: [ + 'completeSession', + 'clearEditingId', + 'setPointedId', + 'clearSelectedIds', + 'pushPointedIdToSelectedIds', + 'setEditingId', + 'startEditSession', + ], + }, + ], BLURRED_EDITING_SHAPE: [ { unless: 'isEditingShape' }, { @@ -672,25 +687,21 @@ const state = createState({ }, { to: 'selecting' }, ], - POINTED_SHAPE: { - unless: 'isPointingEditingShape', - if: 'isPointingTextShape', - do: [ - 'completeSession', - 'clearEditingId', - 'setPointedId', - 'clearSelectedIds', - 'pushPointedIdToSelectedIds', - 'setEditingId', - 'startEditSession', - ], - }, - CANCELLED: [ + POINTED_CANVAS: [ + { unless: 'isEditingShape' }, { get: 'editingShape', if: 'shouldDeleteShape', - do: 'breakSession', - else: 'cancelSession', + do: ['cancelSession', 'deleteSelection'], + }, + { to: 'selecting' }, + ], + CANCELLED: [ + { unless: 'isEditingShape' }, + { + get: 'editingShape', + if: 'shouldDeleteShape', + do: ['cancelSession', 'deleteSelection'], }, { to: 'selecting' }, ], diff --git a/types.ts b/types.ts index 1d5227a29..c49e93f81 100644 --- a/types.ts +++ b/types.ts @@ -607,8 +607,11 @@ export interface ShapeUtility { render( this: ShapeUtility, shape: K, - info: { - isEditing: boolean + info?: { + isEditing?: boolean + isHovered?: boolean + isSelected?: boolean + isCurrentParent?: boolean ref?: React.MutableRefObject } ): JSX.Element diff --git a/utils/utils.ts b/utils/utils.ts index 0838fe9fa..622989b36 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -65,6 +65,43 @@ export function decompress(s: string): string { return s } +/** + * Get whether two objects are shallowly equal. + * + * ### Example + * + *```ts + * shallowEqual(objA, objB) // true + *``` + */ +export function shallowEqual( + objA: Record, + objB: Record +): boolean { + if (objA === objB) return true + + if (!objA || !objB) return false + + const aKeys = Object.keys(objA) + const bKeys = Object.keys(objB) + const len = aKeys.length + + if (bKeys.length !== len) return false + + for (let i = 0; i < len; i++) { + const key = aKeys[i] + + if ( + objA[key] !== objB[key] || + !Object.prototype.hasOwnProperty.call(objB, key) + ) { + return false + } + } + + return true +} + /** * Recursively clone an object or array. * @param obj From 5d12a2fd54fdf81b0ec7c90d8572d7b16b5f8693 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 9 Jul 2021 19:42:43 +0100 Subject: [PATCH 2/8] Fix zoom CSS on zoom --- components/canvas/page.tsx | 1 - state/commands/change-page.ts | 5 +++++ state/commands/create-page.ts | 3 +++ state/commands/move-to-page.ts | 4 ++++ state/hacks.ts | 4 ++++ state/shape-styles.ts | 2 +- 6 files changed, 17 insertions(+), 2 deletions(-) diff --git a/components/canvas/page.tsx b/components/canvas/page.tsx index 3874aec32..84f6d5da6 100644 --- a/components/canvas/page.tsx +++ b/components/canvas/page.tsx @@ -130,7 +130,6 @@ const TranslatedShape = memo( const center = getShapeUtils(shape).getCenter(shape) const rotation = shape.rotation * (180 / Math.PI) - const transform = ` rotate(${rotation}, ${center}) translate(${shape.point}) diff --git a/state/commands/change-page.ts b/state/commands/change-page.ts index 9cbfb7649..84edf02de 100644 --- a/state/commands/change-page.ts +++ b/state/commands/change-page.ts @@ -1,6 +1,7 @@ import Command from './command' import history from '../history' import { Data } from 'types' +import tld from 'utils/tld' import storage from 'state/storage' export default function changePage(data: Data, toPageId: string): void { @@ -17,11 +18,15 @@ export default function changePage(data: Data, toPageId: string): void { storage.loadPage(data, data.document.id, toPageId) data.currentPageId = toPageId data.currentParentId = toPageId + + tld.setZoomCSS(tld.getPageState(data).camera.zoom) }, undo(data) { storage.loadPage(data, data.document.id, fromPageId) data.currentPageId = fromPageId data.currentParentId = fromPageId + + tld.setZoomCSS(tld.getPageState(data).camera.zoom) }, }) ) diff --git a/state/commands/create-page.ts b/state/commands/create-page.ts index 4852c4f1e..25afe50cf 100644 --- a/state/commands/create-page.ts +++ b/state/commands/create-page.ts @@ -2,6 +2,7 @@ import Command from './command' import history from '../history' import { Data, Page, PageState } from 'types' import { uniqueId } from 'utils/utils' +import tld from 'utils/tld' import storage from 'state/storage' export default function createPage(data: Data, goToPage = true): void { @@ -25,6 +26,7 @@ export default function createPage(data: Data, goToPage = true): void { storage.savePage(data, data.document.id, page.id) storage.saveDocumentToLocalStorage(data) + tld.setZoomCSS(tld.getPageState(data).camera.zoom) }, undo(data) { const { page, currentPageId } = snapshot @@ -32,6 +34,7 @@ export default function createPage(data: Data, goToPage = true): void { delete data.pageStates[page.id] data.currentPageId = currentPageId storage.saveDocumentToLocalStorage(data) + tld.setZoomCSS(tld.getPageState(data).camera.zoom) }, }) ) diff --git a/state/commands/move-to-page.ts b/state/commands/move-to-page.ts index 2dceae819..822446e01 100644 --- a/state/commands/move-to-page.ts +++ b/state/commands/move-to-page.ts @@ -87,6 +87,8 @@ export default function moveToPageCommand(data: Data, newPageId: string): void { // Move to the new page data.currentPageId = toPageId + + tld.setZoomCSS(tld.getPageState(data).camera.zoom) }, undo(data) { const fromPageId = newPageId @@ -141,6 +143,8 @@ export default function moveToPageCommand(data: Data, newPageId: string): void { tld.setSelectedIds(data, [...selectedIds]) data.currentPageId = toPageId + + tld.setZoomCSS(tld.getPageState(data).camera.zoom) }, }) ) diff --git a/state/hacks.ts b/state/hacks.ts index 84d3b4596..c5c0b2c3b 100644 --- a/state/hacks.ts +++ b/state/hacks.ts @@ -90,6 +90,8 @@ export function fastZoomUpdate(point: number[], delta: number): void { data.pageStates[data.currentPageId].camera = deepClone(camera) + tld.setZoomCSS(camera.zoom) + state.forceData(freeze(data)) } @@ -116,6 +118,8 @@ export function fastPinchCamera( data.pageStates[data.currentPageId] = { ...pageState } + tld.setZoomCSS(camera.zoom) + state.forceData(freeze(data)) } diff --git a/state/shape-styles.ts b/state/shape-styles.ts index cd90bf0d3..b55af506d 100644 --- a/state/shape-styles.ts +++ b/state/shape-styles.ts @@ -77,5 +77,5 @@ export const defaultStyle: ShapeStyles = { color: ColorStyle.Black, size: SizeStyle.Medium, isFilled: false, - dash: DashStyle.Solid, + dash: DashStyle.Draw, } From 552c8457efde63edaa0de8d648d82917afcb0a95 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 9 Jul 2021 20:43:18 +0100 Subject: [PATCH 3/8] Adjust pointer events for shapes --- components/canvas/defs.tsx | 2 +- components/canvas/page.tsx | 11 +- components/canvas/shape.tsx | 209 -------------------------------- state/shape-utils/arrow.tsx | 31 ++--- state/shape-utils/draw.tsx | 45 ++++--- state/shape-utils/ellipse.tsx | 7 +- state/shape-utils/polyline.tsx | 30 ++--- state/shape-utils/rectangle.tsx | 41 ++++--- 8 files changed, 80 insertions(+), 296 deletions(-) delete mode 100644 components/canvas/shape.tsx diff --git a/components/canvas/defs.tsx b/components/canvas/defs.tsx index 111bc197e..b26154bed 100644 --- a/components/canvas/defs.tsx +++ b/components/canvas/defs.tsx @@ -19,7 +19,7 @@ function ExpandDef() { const zoom = useSelector((s) => tld.getCurrentCamera(s.data).zoom) return ( - + ) } diff --git a/components/canvas/page.tsx b/components/canvas/page.tsx index 84f6d5da6..9430e60f3 100644 --- a/components/canvas/page.tsx +++ b/components/canvas/page.tsx @@ -127,8 +127,9 @@ const TranslatedShape = memo( }: TranslatedShapeProps) => { const rGroup = useRef(null) const events = useShapeEvents(shape.id, isCurrentParent, rGroup) + const utils = getShapeUtils(shape) - const center = getShapeUtils(shape).getCenter(shape) + const center = utils.getCenter(shape) const rotation = shape.rotation * (180 / Math.PI) const transform = ` rotate(${rotation}, ${center}) @@ -136,7 +137,13 @@ const TranslatedShape = memo( ` return ( - + {isEditing && shape.type === ShapeType.Text ? ( ) : ( diff --git a/components/canvas/shape.tsx b/components/canvas/shape.tsx deleted file mode 100644 index 2b78f77ec..000000000 --- a/components/canvas/shape.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import React, { useRef, memo, useEffect } from 'react' -import state, { useSelector } from 'state' -import styled from 'styles' -import { getShapeUtils } from 'state/shape-utils' -import { deepCompareArrays } from 'utils' -import tld from 'utils/tld' -import useShapeEvents from 'hooks/useShapeEvents' -import useShape from 'hooks/useShape' -import vec from 'utils/vec' -import { getShapeStyle } from 'state/shape-styles' -import { Shape as _Shape } from 'types' - -interface ShapeProps { - shape: _Shape - parent?: _Shape -} - -function Shape({ shape, parent }: ShapeProps): JSX.Element { - const rGroup = useRef(null) - - const { id, isHidden, children } = shape - const style = getShapeStyle(shape.style) - const { strokeWidth } = style - - const center = getShapeUtils(shape).getCenter(shape) - const rotation = shape.rotation * (180 / Math.PI) - const parentPoint = parent?.point || [0, 0] - - const transform = ` - translate(${vec.neg(parentPoint)}) - rotate(${rotation}, ${center}) - translate(${shape.point}) - ` - - const isCurrentParent = false - - const events = useShapeEvents(shape.id, isCurrentParent, rGroup) - - // From here on, not reactive—if we're here, we can trust that the - // shape in state is a shape with changes that we need to render. - - const { isParent, isForeignObject, canStyleFill } = getShapeUtils(shape) - - return ( - - {isForeignObject ? ( - - ) : ( - - )} - - {!isHidden && - (isForeignObject ? ( - - ) : ( - - ))} - - {isParent && - children.map((shapeId) => ( - - ))} - - ) -} - -export default memo(Shape) - -// function Def({ id }: { id: string }) { -// const shape = useShape(id) -// if (!shape) return null -// return getShapeUtils(shape).render(shape, { isEditing: false }) -// } - -interface RealShapeProps { - id: string - isParent: boolean - strokeWidth: number - shape: _Shape -} - -const RealShape = memo( - function RealShape({ shape }: RealShapeProps) { - return getShapeUtils(shape).render(shape, { isEditing: false }) - }, - (prev, next) => { - return ( - prev.shape && - next.shape && - next.shape !== prev.shape && - getShapeUtils(next.shape).shouldRender(next.shape, prev.shape) - ) - } -) - -const ForeignObjectHover = memo(function ForeignObjectHover({ - id, -}: { - id: string -}) { - const size = useSelector((s) => { - const shape = tld.getPage(s.data).shapes[id] - if (shape === undefined) return [0, 0] - const bounds = getShapeUtils(shape).getBounds(shape) - - return [bounds.width, bounds.height] - }, deepCompareArrays) - - return ( - - ) -}) - -const ForeignObjectRender = memo(function ForeignObjectRender({ - id, -}: { - id: string -}) { - const shape = useShape(id) - - const rFocusable = useRef(null) - - const isEditing = useSelector((s) => s.data.editingId === id) - - useEffect(() => { - if (isEditing) { - setTimeout(() => { - const elm = rFocusable.current - if (!elm) return - elm.focus() - }, 0) - } - }, [isEditing]) - - if (shape === undefined) return null - - return getShapeUtils(shape).render(shape, { isEditing, ref: rFocusable }) -}) - -const EventSoak = styled('use', { - opacity: 0, - strokeLinecap: 'round', - strokeLinejoin: 'round', - variants: { - variant: { - ghost: { - pointerEvents: 'all', - filter: 'none', - opacity: 0, - }, - hollow: { - pointerEvents: 'stroke', - }, - filled: { - pointerEvents: 'all', - }, - }, - }, -}) - -const StyledGroup = styled('g', { - outline: 'none', - - '& > *[data-shy=true]': { - opacity: 0, - }, - - '&:hover': { - '& > *[data-shy=true]': { - opacity: 1, - }, - }, - - variants: { - isCurrentParent: { - true: { - '& > *[data-shy=true]': { - opacity: 1, - }, - }, - }, - }, -}) diff --git a/state/shape-utils/arrow.tsx b/state/shape-utils/arrow.tsx index 5b0f5d443..70375812a 100644 --- a/state/shape-utils/arrow.tsx +++ b/state/shape-utils/arrow.tsx @@ -7,7 +7,6 @@ import { getBoundsFromPoints, translateBounds, pointInBounds, - pointInCircle, circleFromThreePoints, isAngleBetween, getPerfectDashProps, @@ -102,8 +101,8 @@ const arrow = registerShapeUtils({ return shape.handles !== prev.handles || shape.style !== prev.style }, - render(shape, { isHovered }) { - const { id, bend, handles, style } = shape + render(shape) { + const { bend, handles, style } = shape const { start, end, bend: _bend } = handles const isStraightLine = @@ -146,11 +145,11 @@ const arrow = registerShapeUtils({ ({ strokeDasharray={strokeDasharray} strokeDashoffset={strokeDashoffset} strokeLinecap="round" + strokeLinejoin="round" /> ) @@ -206,6 +206,7 @@ const arrow = registerShapeUtils({ strokeDasharray="none" strokeDashoffset="none" strokeLinecap="round" + strokeLinejoin="round" /> ({ strokeDasharray={strokeDasharray} strokeDashoffset={strokeDashoffset} strokeLinecap="round" + strokeLinejoin="round" /> ) @@ -223,7 +225,7 @@ const arrow = registerShapeUtils({ const sw = strokeWidth * 1.618 return ( - + {shaftPath} {shape.decorations.start === Decoration.Arrow && ( ({ strokeDasharray="none" strokeLinecap="round" strokeLinejoin="round" + pointerEvents="stroke" /> )} {shape.decorations.end === Decoration.Arrow && ( @@ -247,6 +250,7 @@ const arrow = registerShapeUtils({ strokeDasharray="none" strokeLinecap="round" strokeLinejoin="round" + pointerEvents="stroke" /> )} @@ -308,21 +312,8 @@ const arrow = registerShapeUtils({ return vec.add(shape.point, vec.med(start.point, end.point)) }, - hitTest(shape, point) { - const { start, end } = shape.handles - if (shape.bend === 0) { - return ( - vec.distanceToLineSegment( - start.point, - end.point, - vec.sub(point, shape.point) - ) < 4 - ) - } - - const [cx, cy, r] = getCtp(shape) - - return !pointInCircle(point, vec.add(shape.point, [cx, cy]), r - 4) + hitTest() { + return true }, hitTestBounds(this, shape, brushBounds) { diff --git a/state/shape-utils/draw.tsx b/state/shape-utils/draw.tsx index 238f6e19f..9dc5f240a 100644 --- a/state/shape-utils/draw.tsx +++ b/state/shape-utils/draw.tsx @@ -41,13 +41,14 @@ const draw = registerShapeUtils({ }, render(shape, { isHovered }) { - const { id, points, style } = shape + const { points, style } = shape const styles = getShapeStyle(style) const strokeWidth = +styles.strokeWidth const shouldFill = + style.isFilled && points.length > 3 && vec.dist(points[0], points[points.length - 1]) < +styles.strokeWidth * 2 @@ -58,11 +59,11 @@ const draw = registerShapeUtils({ return ( ) @@ -80,15 +81,15 @@ const draw = registerShapeUtils({ }) return ( - + <> {shouldFill && ( )} ({ strokeWidth={strokeWidth} strokeLinejoin="round" strokeLinecap="round" + pointerEvents="all" filter={isHovered ? 'url(#expand)' : 'none'} /> - + ) } @@ -129,29 +131,29 @@ const draw = registerShapeUtils({ const sw = strokeWidth * 1.618 return ( - - {style.dash !== DashStyle.Solid && ( - - )} + <> + - + ) }, @@ -174,13 +176,8 @@ const draw = registerShapeUtils({ return getBoundsCenter(this.getBounds(shape)) }, - hitTest(shape, point) { - const pt = vec.sub(point, shape.point) - const min = +getShapeStyle(shape.style).strokeWidth - return shape.points.some( - (curr, i) => - i > 0 && vec.distanceToLineSegment(shape.points[i - 1], curr, pt) < min - ) + hitTest() { + return true }, hitTestBounds(this, shape, brushBounds) { diff --git a/state/shape-utils/ellipse.tsx b/state/shape-utils/ellipse.tsx index d812a2148..8e7412ad5 100644 --- a/state/shape-utils/ellipse.tsx +++ b/state/shape-utils/ellipse.tsx @@ -58,16 +58,16 @@ const ellipse = registerShapeUtils({ const path = pathCache.get(shape) return ( - + {style.isFilled && ( )} ({ fill={styles.stroke} stroke={styles.stroke} strokeWidth={strokeWidth} + pointerEvents="all" filter={isHovered ? 'url(#expand)' : 'none'} /> @@ -97,7 +98,6 @@ const ellipse = registerShapeUtils({ return ( ({ strokeWidth={sw} strokeDasharray={strokeDasharray} strokeDashoffset={strokeDashoffset} + pointerEvents={style.isFilled ? 'all' : 'stroke'} filter={isHovered ? 'url(#expand)' : 'none'} /> ) diff --git a/state/shape-utils/polyline.tsx b/state/shape-utils/polyline.tsx index 133a84b87..4571f57e0 100644 --- a/state/shape-utils/polyline.tsx +++ b/state/shape-utils/polyline.tsx @@ -28,19 +28,20 @@ const polyline = registerShapeUtils({ shouldRender(shape, prev) { return shape.points !== prev.points || shape.style !== prev.style }, - render(shape, { isHovered }) { - const { id, points } = shape + render(shape) { + const { points, style } = shape - const styles = getShapeStyle(shape.style) + const styles = getShapeStyle(style) return ( ) }, @@ -62,19 +63,8 @@ const polyline = registerShapeUtils({ return [bounds.minX + bounds.width / 2, bounds.minY + bounds.height / 2] }, - hitTest(shape, point) { - const pt = vec.sub(point, shape.point) - let prev = shape.points[0] - - for (let i = 1; i < shape.points.length; i++) { - const curr = shape.points[i] - if (vec.distanceToLineSegment(prev, curr, pt) < 4) { - return true - } - prev = curr - } - - return false + hitTest() { + return true }, hitTestBounds(this, shape, brushBounds) { @@ -126,7 +116,7 @@ const polyline = registerShapeUtils({ canTransform: true, canChangeAspectRatio: true, - canStyleFill: false, + canStyleFill: true, }) export default polyline diff --git a/state/shape-utils/rectangle.tsx b/state/shape-utils/rectangle.tsx index 2508479ff..31ef55c10 100644 --- a/state/shape-utils/rectangle.tsx +++ b/state/shape-utils/rectangle.tsx @@ -39,26 +39,29 @@ const rectangle = registerShapeUtils({ }) return ( - - + <> + {style.isFilled && ( + + )} - + ) } @@ -98,17 +101,21 @@ const rectangle = registerShapeUtils({ }) return ( - + <> - {paths} - + + {paths} + + ) }, From 956c0717df69731a9fd53a2acd08ceea8c6b7c9e Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 9 Jul 2021 20:52:08 +0100 Subject: [PATCH 4/8] Further simplifies shape tree --- state/shape-utils/dot.tsx | 6 +----- state/shape-utils/ellipse.tsx | 10 ++++------ state/shape-utils/group.tsx | 3 +-- state/shape-utils/ray.tsx | 8 ++++---- state/shape-utils/text.tsx | 11 +++-------- 5 files changed, 13 insertions(+), 25 deletions(-) diff --git a/state/shape-utils/dot.tsx b/state/shape-utils/dot.tsx index 6419f6544..302f9d960 100644 --- a/state/shape-utils/dot.tsx +++ b/state/shape-utils/dot.tsx @@ -20,13 +20,9 @@ const dot = registerShapeUtils({ }, render(shape) { - const { id } = shape - const styles = getShapeStyle(shape.style) - return ( - - ) + return }, getBounds(shape) { diff --git a/state/shape-utils/ellipse.tsx b/state/shape-utils/ellipse.tsx index 8e7412ad5..062bef8c0 100644 --- a/state/shape-utils/ellipse.tsx +++ b/state/shape-utils/ellipse.tsx @@ -42,8 +42,8 @@ const ellipse = registerShapeUtils({ ) }, - render(shape, { isHovered }) { - const { id, radiusX, radiusY, style } = shape + render(shape) { + const { radiusX, radiusY, style } = shape const styles = getShapeStyle(style) const strokeWidth = +styles.strokeWidth @@ -58,7 +58,7 @@ const ellipse = registerShapeUtils({ const path = pathCache.get(shape) return ( - + <> {style.isFilled && ( ({ stroke={styles.stroke} strokeWidth={strokeWidth} pointerEvents="all" - filter={isHovered ? 'url(#expand)' : 'none'} /> - + ) } @@ -108,7 +107,6 @@ const ellipse = registerShapeUtils({ strokeDasharray={strokeDasharray} strokeDashoffset={strokeDashoffset} pointerEvents={style.isFilled ? 'all' : 'stroke'} - filter={isHovered ? 'url(#expand)' : 'none'} /> ) }, diff --git a/state/shape-utils/group.tsx b/state/shape-utils/group.tsx index 025b6ccd1..e6f43ed5c 100644 --- a/state/shape-utils/group.tsx +++ b/state/shape-utils/group.tsx @@ -26,11 +26,10 @@ const group = registerShapeUtils({ }, render(shape) { - const { id, size } = shape + const { size } = shape return ( ({ shouldRender(shape, prev) { return shape.direction !== prev.direction || shape.style !== prev.style }, - render(shape, { isHovered }) { - const { id, direction } = shape + render(shape) { + const { direction } = shape const styles = getShapeStyle(shape.style) const [x2, y2] = vec.add([0, 0], vec.mul(direction, 10000)) return ( - + <> - + ) }, diff --git a/state/shape-utils/text.tsx b/state/shape-utils/text.tsx index 9bcec55e5..145ceb5e6 100644 --- a/state/shape-utils/text.tsx +++ b/state/shape-utils/text.tsx @@ -70,7 +70,7 @@ const text = registerShapeUtils({ ) }, - render(shape, { isEditing, isHovered, ref }) { + render(shape, { isEditing, ref }) { const { id, text, style } = shape const styles = getShapeStyle(style) const font = getFontStyle(shape.scale, shape.style) @@ -131,11 +131,7 @@ const text = registerShapeUtils({ if (!isEditing) { return ( - + <> {text.split('\n').map((str, i) => ( ({ {str} ))} - + ) } @@ -167,7 +163,6 @@ const text = registerShapeUtils({ return ( Date: Fri, 9 Jul 2021 21:04:41 +0100 Subject: [PATCH 5/8] Cleans up page / creates shape file --- components/canvas/page.tsx | 200 ++++++++++-------------------------- components/canvas/shape.tsx | 112 ++++++++++++++++++++ 2 files changed, 165 insertions(+), 147 deletions(-) create mode 100644 components/canvas/shape.tsx diff --git a/components/canvas/page.tsx b/components/canvas/page.tsx index 9430e60f3..063861342 100644 --- a/components/canvas/page.tsx +++ b/components/canvas/page.tsx @@ -1,37 +1,46 @@ import { useSelector } from 'state' import tld from 'utils/tld' -import useShapeEvents from 'hooks/useShapeEvents' -import { Data, Shape, ShapeType, TextShape } from 'types' +import { Data, Shape, ShapeType } from 'types' import { getShapeUtils } from 'state/shape-utils' -import { boundsCollide, boundsContain, shallowEqual } from 'utils' -import { memo, useRef } from 'react' +import { boundsCollide, boundsContain } from 'utils' +import ShapeComponent from './shape' /* -On each state change, compare node ids of all shapes -on the current page. Kind of expensive but only happens -here; and still cheaper than any other pattern I've found. +On each state change, populate a tree structure with all of +the shapes that we need to render.. */ +interface Node { + shape: Shape + children: Node[] + isEditing: boolean + isHovered: boolean + isSelected: boolean + isCurrentParent: boolean +} + export default function Page(): JSX.Element { - // Get the shapes that fit into the current window + // Get a tree of shapes to render const shapeTree = useSelector((s) => { - const allowHovers = s.isInAny('selecting', 'text', 'editingShape') + // Get the shapes that fit into the current viewport const viewport = tld.getViewport(s.data) const shapesToShow = s.values.currentShapes.filter((shape) => { - if (shape.type === ShapeType.Ray || shape.type === ShapeType.Line) { - return true - } - const shapeBounds = getShapeUtils(shape).getBounds(shape) return ( + shape.type === ShapeType.Ray || + shape.type === ShapeType.Line || boundsContain(viewport, shapeBounds) || boundsCollide(viewport, shapeBounds) ) }) + // Should we allow shapes to be hovered? + const allowHovers = s.isInAny('selecting', 'text', 'editingShape') + + // Populate the shape tree const tree: Node[] = [] shapesToShow.forEach((shape) => @@ -50,15 +59,39 @@ export default function Page(): JSX.Element { ) } -type Node = { - shape: Shape - children: Node[] - isEditing: boolean - isHovered: boolean - isSelected: boolean - isCurrentParent: boolean +interface ShapeNodeProps { + node: Node + parentPoint?: number[] } +const ShapeNode = ({ + node: { shape, children, isEditing, isHovered, isSelected, isCurrentParent }, +}: ShapeNodeProps) => { + return ( + <> + + {children.map((childNode) => ( + + ))} + + ) +} + +/** + * Populate the shape tree. This helper is recursive and only one call is needed. + * + * ### Example + * + *```ts + * addDataToTree(data, selectedIds, allowHovers, branch, shape) + *``` + */ function addToTree( data: Data, selectedIds: string[], @@ -86,130 +119,3 @@ function addToTree( }) } } - -const ShapeNode = ({ - node: { shape, children, isEditing, isHovered, isSelected, isCurrentParent }, -}: { - node: Node - parentPoint?: number[] -}) => { - return ( - <> - - {children.map((childNode) => ( - - ))} - - ) -} - -interface TranslatedShapeProps { - shape: Shape - isEditing: boolean - isHovered: boolean - isSelected: boolean - isCurrentParent: boolean -} - -const TranslatedShape = memo( - ({ - shape, - isEditing, - isHovered, - isSelected, - isCurrentParent, - }: TranslatedShapeProps) => { - const rGroup = useRef(null) - const events = useShapeEvents(shape.id, isCurrentParent, rGroup) - const utils = getShapeUtils(shape) - - const center = utils.getCenter(shape) - const rotation = shape.rotation * (180 / Math.PI) - const transform = ` - rotate(${rotation}, ${center}) - translate(${shape.point}) - ` - - return ( - - {isEditing && shape.type === ShapeType.Text ? ( - - ) : ( - - )} - - ) - }, - shallowEqual -) - -interface RenderedShapeProps { - shape: Shape - isEditing: boolean - isHovered: boolean - isSelected: boolean - isCurrentParent: boolean -} - -const RenderedShape = memo( - function RenderedShape({ - shape, - isEditing, - isHovered, - isSelected, - isCurrentParent, - }: RenderedShapeProps) { - return getShapeUtils(shape).render(shape, { - isEditing, - isHovered, - isSelected, - isCurrentParent, - }) - }, - (prev, next) => { - if ( - prev.isEditing !== next.isEditing || - prev.isHovered !== next.isHovered || - prev.isSelected !== next.isSelected || - prev.isCurrentParent !== next.isCurrentParent - ) { - return false - } - - if (next.shape !== prev.shape) { - return !getShapeUtils(next.shape).shouldRender(next.shape, prev.shape) - } - - return true - } -) - -function EditingTextShape({ shape }: { shape: TextShape }) { - const ref = useRef(null) - - return getShapeUtils(shape).render(shape, { - ref, - isEditing: true, - isHovered: false, - isSelected: false, - isCurrentParent: false, - }) -} diff --git a/components/canvas/shape.tsx b/components/canvas/shape.tsx new file mode 100644 index 000000000..a26184527 --- /dev/null +++ b/components/canvas/shape.tsx @@ -0,0 +1,112 @@ +import useShapeEvents from 'hooks/useShapeEvents' +import { Shape as _Shape, ShapeType, TextShape } from 'types' +import { getShapeUtils } from 'state/shape-utils' +import { shallowEqual } from 'utils' +import { memo, useRef } from 'react' + +interface ShapeProps { + shape: _Shape + isEditing: boolean + isHovered: boolean + isSelected: boolean + isCurrentParent: boolean +} + +const Shape = memo( + ({ + shape, + isEditing, + isHovered, + isSelected, + isCurrentParent, + }: ShapeProps) => { + const rGroup = useRef(null) + const events = useShapeEvents(shape.id, isCurrentParent, rGroup) + const utils = getShapeUtils(shape) + + const center = utils.getCenter(shape) + const rotation = shape.rotation * (180 / Math.PI) + const transform = ` + rotate(${rotation}, ${center}) + translate(${shape.point}) + ` + + return ( + + {isEditing && shape.type === ShapeType.Text ? ( + + ) : ( + + )} + + ) + }, + shallowEqual +) + +export default Shape + +interface RenderedShapeProps { + shape: _Shape + isEditing: boolean + isHovered: boolean + isSelected: boolean + isCurrentParent: boolean +} + +const RenderedShape = memo( + function RenderedShape({ + shape, + isEditing, + isHovered, + isSelected, + isCurrentParent, + }: RenderedShapeProps) { + return getShapeUtils(shape).render(shape, { + isEditing, + isHovered, + isSelected, + isCurrentParent, + }) + }, + (prev, next) => { + if ( + prev.isEditing !== next.isEditing || + prev.isHovered !== next.isHovered || + prev.isSelected !== next.isSelected || + prev.isCurrentParent !== next.isCurrentParent + ) { + return false + } + + if (next.shape !== prev.shape) { + return !getShapeUtils(next.shape).shouldRender(next.shape, prev.shape) + } + + return true + } +) + +function EditingTextShape({ shape }: { shape: TextShape }) { + const ref = useRef(null) + + return getShapeUtils(shape).render(shape, { + ref, + isEditing: true, + isHovered: false, + isSelected: false, + isCurrentParent: false, + }) +} From e9d070f354794f497adbe046d78f5a4756746371 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 9 Jul 2021 21:19:10 +0100 Subject: [PATCH 6/8] Show bounds when pinching --- components/canvas/bounds/bounding-box.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/components/canvas/bounds/bounding-box.tsx b/components/canvas/bounds/bounding-box.tsx index 60e45401c..fa90281d9 100644 --- a/components/canvas/bounds/bounding-box.tsx +++ b/components/canvas/bounds/bounding-box.tsx @@ -11,7 +11,9 @@ import RotateHandle from './rotate-handle' export default function Bounds(): JSX.Element { const isBrushing = useSelector((s) => s.isIn('brushSelecting')) - const isSelecting = useSelector((s) => s.isIn('selecting')) + const shouldDisplay = useSelector((s) => + s.isInAny('selecting', 'selectPinching') + ) const zoom = useSelector((s) => tld.getCurrentCamera(s.data).zoom) @@ -38,7 +40,7 @@ export default function Bounds(): JSX.Element { if (!bounds) return null - if (!isSelecting) return null + if (!shouldDisplay) return null if (isSingleHandles) return null From 55ecec9e7bfec0b0cba2e7cae2d449bc48b2dca9 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 9 Jul 2021 21:32:04 +0100 Subject: [PATCH 7/8] Fixes text selection on touch devices. --- state/shape-utils/text.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/state/shape-utils/text.tsx b/state/shape-utils/text.tsx index 145ceb5e6..676217e2f 100644 --- a/state/shape-utils/text.tsx +++ b/state/shape-utils/text.tsx @@ -116,7 +116,8 @@ const text = registerShapeUtils({ setTimeout(() => state.send('BLURRED_EDITING_SHAPE', { id }), 0) } - function handleFocus() { + function handleFocus(e: React.FocusEvent) { + e.currentTarget.select() state.send('FOCUSED_EDITING_SHAPE', { id }) } From bdcdb09162520dfef4f1a54f974682836057cd04 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 9 Jul 2021 21:39:29 +0100 Subject: [PATCH 8/8] Fixes "done" key on iOS --- hooks/useCanvasEvents.ts | 15 ++++++++++++++- state/state.ts | 6 +++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/hooks/useCanvasEvents.ts b/hooks/useCanvasEvents.ts index e1a486adc..55aca9fbb 100644 --- a/hooks/useCanvasEvents.ts +++ b/hooks/useCanvasEvents.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { MutableRefObject, useCallback } from 'react' +import { MutableRefObject, useCallback, useEffect } from 'react' import state from 'state' import { fastBrushSelect, @@ -74,6 +74,19 @@ export default function useCanvasEvents( // } }, []) + // Send event on iOS when a user presses the "Done" key while editing a text element + useEffect(() => { + function handleFocusOut() { + state.send('BLURRED_EDITING_SHAPE') + } + + document.addEventListener('focusout', handleFocusOut) + + return () => { + document.removeEventListener('focusout', handleFocusOut) + } + }, []) + return { onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, diff --git a/state/state.ts b/state/state.ts index 01ffed47b..0bad830ab 100644 --- a/state/state.ts +++ b/state/state.ts @@ -679,8 +679,8 @@ const state = createState({ }, ], BLURRED_EDITING_SHAPE: [ - { unless: 'isEditingShape' }, { + unless: 'isEditingShape', get: 'editingShape', if: 'shouldDeleteShape', do: ['cancelSession', 'deleteSelection'], @@ -688,8 +688,8 @@ const state = createState({ { to: 'selecting' }, ], POINTED_CANVAS: [ - { unless: 'isEditingShape' }, { + unless: 'isEditingShape', get: 'editingShape', if: 'shouldDeleteShape', do: ['cancelSession', 'deleteSelection'], @@ -697,8 +697,8 @@ const state = createState({ { to: 'selecting' }, ], CANCELLED: [ - { unless: 'isEditingShape' }, { + unless: 'isEditingShape', get: 'editingShape', if: 'shouldDeleteShape', do: ['cancelSession', 'deleteSelection'],