Fixes events with shapes, adds test for selection

This commit is contained in:
Steve Ruiz 2021-06-23 15:39:14 +01:00
parent e265a85d7b
commit d5fe5612e1
13 changed files with 13504 additions and 81 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,56 +1,19 @@
import * as json from './__mocks__/document.json'
import state from 'state'
import { point } from './test-utils'
import inputs from 'state/inputs'
import { getSelectedIds, setToArray } from 'utils/utils'
const rectangleId = '1f6c251c-e12e-40b4-8dd2-c1847d80b72f'
const arrowId = '5ca167d7-54de-47c9-aa8f-86affa25e44d'
import * as json from './__mocks__/document.json'
describe('project', () => {
state.reset()
state.enableLog(true)
it('mounts the state', () => {
state.enableLog(true)
state
.send('MOUNTED')
.send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
state.send('MOUNTED')
expect(state.data.document).toMatchSnapshot('data after initial mount')
expect(state.isIn('ready')).toBe(true)
})
it('selects and deselects a shape', () => {
expect(setToArray(getSelectedIds(state.data))).toStrictEqual([])
state
.send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
.send('STOPPED_POINTING', inputs.pointerUp(point()))
expect(setToArray(getSelectedIds(state.data))).toStrictEqual([rectangleId])
state
.send('POINTED_CANVAS', inputs.pointerDown(point(), 'canvas'))
.send('STOPPED_POINTING', inputs.pointerUp(point()))
expect(setToArray(getSelectedIds(state.data))).toStrictEqual([])
})
it('selects multiple shapes', () => {
expect(setToArray(getSelectedIds(state.data))).toStrictEqual([])
state
.send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
.send('STOPPED_POINTING', inputs.pointerUp(point()))
expect(setToArray(getSelectedIds(state.data))).toStrictEqual([rectangleId])
state.send(
'POINTED_SHAPE',
inputs.pointerDown(point({ shiftKey: true }), arrowId)
)
// state.send('STOPPED_POINTING', inputs.pointerUp(point()))
expect(setToArray(getSelectedIds(state.data))).toStrictEqual([
rectangleId,
arrowId,
])
it('loads file from json', () => {
state.send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
expect(state.isIn('ready')).toBe(true)
expect(state.data.document).toMatchSnapshot('data after mount from file')
})
})

142
__tests__/selection.test.ts Normal file
View file

@ -0,0 +1,142 @@
import state from 'state'
import inputs from 'state/inputs'
import { idsAreSelected, point } from './test-utils'
import * as json from './__mocks__/document.json'
const rectangleId = '1f6c251c-e12e-40b4-8dd2-c1847d80b72f'
const arrowId = '5ca167d7-54de-47c9-aa8f-86affa25e44d'
// Mount the state and load the test file from json
state.reset()
state.send('MOUNTED').send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
describe('selection', () => {
it('selects a shape', () => {
state
.send('CANCELED')
.send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
.send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
expect(idsAreSelected(state.data, [rectangleId])).toBe(true)
})
it('selects and deselects a shape', () => {
state
.send('CANCELED')
.send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
.send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
expect(idsAreSelected(state.data, [rectangleId])).toBe(true)
state
.send('POINTED_CANVAS', inputs.pointerDown(point(), 'canvas'))
.send('STOPPED_POINTING', inputs.pointerUp(point(), 'canvas'))
expect(idsAreSelected(state.data, [])).toBe(true)
})
it('selects multiple shapes', () => {
expect(idsAreSelected(state.data, [])).toBe(true)
state
.send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
.send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
.send(
'POINTED_SHAPE',
inputs.pointerDown(point({ shiftKey: true }), arrowId)
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(point({ shiftKey: true }), arrowId)
)
expect(idsAreSelected(state.data, [rectangleId, arrowId])).toBe(true)
})
it('shift-selects to deselect shapes', () => {
state
.send('CANCELLED')
.send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
.send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
.send(
'POINTED_SHAPE',
inputs.pointerDown(point({ shiftKey: true }), arrowId)
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(point({ shiftKey: true }), arrowId)
)
.send(
'POINTED_SHAPE',
inputs.pointerDown(point({ shiftKey: true }), rectangleId)
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(point({ shiftKey: true }), rectangleId)
)
expect(idsAreSelected(state.data, [arrowId])).toBe(true)
})
it('single-selects shape in selection on pointerup', () => {
state
.send('CANCELLED')
.send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
.send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
.send(
'POINTED_SHAPE',
inputs.pointerDown(point({ shiftKey: true }), arrowId)
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(point({ shiftKey: true }), arrowId)
)
expect(idsAreSelected(state.data, [rectangleId, arrowId])).toBe(true)
state.send('POINTED_SHAPE', inputs.pointerDown(point(), arrowId))
expect(idsAreSelected(state.data, [rectangleId, arrowId])).toBe(true)
state.send('STOPPED_POINTING', inputs.pointerUp(point(), arrowId))
expect(idsAreSelected(state.data, [arrowId])).toBe(true)
})
it('selects shapes if shift key is lifted before pointerup', () => {
state
.send('CANCELLED')
.send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
.send('STOPPED_POINTING', inputs.pointerUp(point(), rectangleId))
.send(
'POINTED_SHAPE',
inputs.pointerDown(point({ shiftKey: true }), arrowId)
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(point({ shiftKey: true }), arrowId)
)
.send(
'POINTED_SHAPE',
inputs.pointerDown(point({ shiftKey: true }), arrowId)
)
.send('STOPPED_POINTING', inputs.pointerUp(point(), arrowId))
expect(idsAreSelected(state.data, [arrowId])).toBe(true)
})
it('does not select on meta-click', () => {
state
.send('CANCELLED')
.send(
'POINTED_SHAPE',
inputs.pointerDown(point({ ctrlKey: true }), rectangleId)
)
.send(
'STOPPED_POINTING',
inputs.pointerUp(point({ ctrlKey: true }), rectangleId)
)
expect(idsAreSelected(state.data, [])).toBe(true)
})
})

View file

@ -1,10 +1,13 @@
import { Data } from 'types'
import { getSelectedIds } from 'utils/utils'
interface PointerOptions {
id?: string
x?: number
y?: number
shiftKey?: boolean
altKey?: boolean
metaKey?: boolean
ctrlKey?: boolean
}
export function point(
@ -16,15 +19,27 @@ export function point(
y = 0,
shiftKey = false,
altKey = false,
metaKey = false,
ctrlKey = false,
} = options
return {
shiftKey,
altKey,
metaKey,
ctrlKey,
pointerId: id,
clientX: x,
clientY: y,
} as any
}
export function idsAreSelected(
data: Data,
ids: string[],
strict = true
): boolean {
const selectedIds = getSelectedIds(data)
return (
(strict ? selectedIds.size === ids.length : true) &&
ids.every((id) => selectedIds.has(id))
)
}

View file

@ -21,7 +21,7 @@ function handlePointerUp(e: React.PointerEvent<SVGRectElement>) {
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
e.currentTarget.releasePointerCapture(e.pointerId)
state.send('STOPPED_POINTING', inputs.pointerUp(e))
state.send('STOPPED_POINTING', inputs.pointerUp(e, 'bounds'))
}
export default function BoundsBg(): JSX.Element {

View file

@ -39,6 +39,6 @@ const Def = memo(function Def({ id }: { id: string }) {
return React.cloneElement(
getShapeUtils(shape).render(shape, { isEditing: false }),
{ ...style }
{ id, ...style }
)
})

View file

@ -2,7 +2,7 @@ import React, { useRef, memo, useEffect } from 'react'
import { useSelector } from 'state'
import styled from 'styles'
import { getShapeUtils } from 'state/shape-utils'
import { getPage, isMobile } from 'utils/utils'
import { getPage, getSelectedIds, isMobile } from 'utils/utils'
import { Shape as _Shape } from 'types'
import useShapeEvents from 'hooks/useShapeEvents'
import vec from 'utils/vec'
@ -22,6 +22,8 @@ function Shape({ id, isSelecting, parentPoint }: ShapeProps): JSX.Element {
const isEditing = useSelector((s) => s.data.editingId === id)
const isSelected = useSelector((s) => getSelectedIds(s.data).has(id))
const shape = useSelector((s) => getPage(s.data).shapes[id])
const events = useShapeEvents(id, getShapeUtils(shape)?.isParent, rGroup)
@ -62,9 +64,11 @@ function Shape({ id, isSelecting, parentPoint }: ShapeProps): JSX.Element {
id={id + '-group'}
ref={rGroup}
transform={transform}
isSelected={isSelected}
device={isMobileDevice ? 'mobile' : 'desktop'}
{...events}
>
{isSelecting && !isShy && (
{!isShy && (
<>
{isForeignObject ? (
<HoverIndicator
@ -73,15 +77,13 @@ function Shape({ id, isSelecting, parentPoint }: ShapeProps): JSX.Element {
height={bounds.height}
strokeWidth={1.5}
variant={'ghost'}
{...events}
/>
) : (
<HoverIndicator
as="use"
href={'#' + id}
strokeWidth={+style.strokeWidth + 4}
strokeWidth={+style.strokeWidth + 5}
variant={getShapeUtils(shape).canStyleFill ? 'filled' : 'hollow'}
{...events}
/>
)}
</>
@ -201,10 +203,10 @@ const StyledGroup = styled('g', {
isSelected: 'true',
css: {
[`&:hover ${HoverIndicator}`]: {
opacity: '0.3',
opacity: '0.25',
},
[`&:active ${HoverIndicator}`]: {
opacity: '0.3',
opacity: '0.25',
},
},
},

View file

@ -50,7 +50,7 @@ export default function useBoundsEvents(handle: Edge | Corner | 'rotate') {
e.stopPropagation()
e.currentTarget.releasePointerCapture(e.pointerId)
e.currentTarget.replaceWith(e.currentTarget)
state.send('STOPPED_POINTING', inputs.pointerUp(e))
state.send('STOPPED_POINTING', inputs.pointerUp(e, 'bounds'))
}, [])
return { onPointerDown, onPointerMove, onPointerUp }

View file

@ -54,8 +54,14 @@ export default function useCanvasEvents(
const handlePointerUp = useCallback((e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
rCanvas.current.releasePointerCapture(e.pointerId)
state.send('STOPPED_POINTING', { id: 'canvas', ...inputs.pointerUp(e) })
state.send('STOPPED_POINTING', {
id: 'canvas',
...inputs.pointerUp(e, 'canvas'),
})
}, [])
const handleTouchStart = useCallback(() => {

View file

@ -30,7 +30,7 @@ export default function useHandleEvents(
if (isDoubleClick && !(info.altKey || info.metaKey)) {
state.send('DOUBLE_POINTED_HANDLE', info)
} else {
state.send('STOPPED_POINTING', inputs.pointerUp(e))
state.send('STOPPED_POINTING', inputs.pointerUp(e, id))
}
},
[id]

View file

@ -35,7 +35,7 @@ export default function useShapeEvents(
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
rGroup.current.releasePointerCapture(e.pointerId)
state.send('STOPPED_POINTING', inputs.pointerUp(e))
state.send('STOPPED_POINTING', inputs.pointerUp(e, id))
},
[id]
)

View file

@ -70,14 +70,14 @@ const rectangle = registerShapeUtils<RectangleShape>({
const sw = strokeWidth * 1.618
const w = Math.max(0, size[0])
const h = Math.max(0, size[1])
const w = Math.max(0, size[0] - sw / 2)
const h = Math.max(0, size[1] - sw / 2)
const strokes: [number[], number[], number][] = [
[[sw / 2, sw / 2], [w - sw, sw / 2], w - sw],
[[w - sw / 2, sw / 2], [w - sw / 2, h - sw / 2], h - sw],
[[w - sw / 2, h - sw / 2], [sw / 2, h - sw / 2], w - sw],
[[sw / 2, h - sw / 2], [sw / 2, sw / 2], h - sw],
[[sw / 2, sw / 2], [w, sw / 2], w - sw / 2],
[[w, sw / 2], [w, h], h - sw / 2],
[[w, h], [sw / 2, h], w - sw / 2],
[[sw / 2, h], [sw / 2, sw / 2], h - sw / 2],
]
const paths = strokes.map(([start, end, length], i) => {
@ -108,8 +108,8 @@ const rectangle = registerShapeUtils<RectangleShape>({
<rect
x={sw / 2}
y={sw / 2}
width={size[0] - sw}
height={size[1] - sw}
width={w}
height={h}
fill={styles.fill}
stroke="none"
/>

View file

@ -229,6 +229,7 @@ const state = createState({
initial: 'notPointing',
states: {
notPointing: {
onEnter: 'clearPointedId',
on: {
CANCELLED: 'clearSelectedIds',
STARTED_PINCHING: { to: 'pinching' },
@ -282,7 +283,7 @@ const state = createState({
unless: 'isPointedShapeSelected',
then: {
if: 'isPressingShiftKey',
do: 'pushPointedIdToSelectedIds',
do: ['pushPointedIdToSelectedIds', 'clearPointedId'],
else: ['clearSelectedIds', 'pushPointedIdToSelectedIds'],
},
},
@ -334,6 +335,7 @@ const state = createState({
},
pointingBounds: {
on: {
CANCELLED: { to: 'notPointing' },
STOPPED_POINTING_BOUNDS: [],
STOPPED_POINTING: [
{
@ -342,15 +344,17 @@ const state = createState({
},
{
if: 'isPressingShiftKey',
then: [
{
if: 'isPointedShapeSelected',
do: 'pullPointedIdFromSelectedIds',
},
],
then: {
if: 'isPointedShapeSelected',
do: 'pullPointedIdFromSelectedIds',
},
else: {
unless: 'isPointingBounds',
do: ['clearSelectedIds', 'pushPointedIdToSelectedIds'],
if: 'isPointingShape',
do: [
'clearSelectedIds',
'setPointedId',
'pushPointedIdToSelectedIds',
],
},
},
{ to: 'notPointing' },
@ -915,6 +919,9 @@ const state = createState({
screenToWorld(payload.point, data)
)
},
hasPointedId(data, payload: PointerInfo) {
return getShape(data, payload.target) !== undefined
},
isPointingRotationHandle(
data,
payload: { target: Edge | Corner | 'rotate' }
@ -1743,6 +1750,7 @@ function getParentId(data: Data, id: string) {
function getPointedId(data: Data, id: string) {
const shape = getPage(data).shapes[id]
if (!shape) return id
return shape.parentId === data.currentParentId ||
shape.parentId === data.currentPageId