Fixes events with shapes, adds test for selection
This commit is contained in:
parent
e265a85d7b
commit
d5fe5612e1
13 changed files with 13504 additions and 81 deletions
13287
__tests__/__snapshots__/project.test.ts.snap
Normal file
13287
__tests__/__snapshots__/project.test.ts.snap
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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
142
__tests__/selection.test.ts
Normal 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)
|
||||
})
|
||||
})
|
|
@ -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))
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -39,6 +39,6 @@ const Def = memo(function Def({ id }: { id: string }) {
|
|||
|
||||
return React.cloneElement(
|
||||
getShapeUtils(shape).render(shape, { isEditing: false }),
|
||||
{ ...style }
|
||||
{ id, ...style }
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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]
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue