From 953a6ec921e09206fec0ba4aa575ac0c9acba972 Mon Sep 17 00:00:00 2001 From: Pob <590650@gmail.com> Date: Mon, 1 Aug 2022 14:05:53 +0700 Subject: [PATCH 1/7] WIP --- package.json | 4 +- src/CanvasForSelection.tsx | 478 +++++++++++++------ src/selectionToolHelpers/eventHandlers.ts | 249 +--------- src/selectionToolHelpers/selectionMachine.ts | 93 ++++ yarn.lock | 23 + 5 files changed, 455 insertions(+), 392 deletions(-) create mode 100644 src/selectionToolHelpers/selectionMachine.ts diff --git a/package.json b/package.json index 34a9a26..0f885f9 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@types/node": "^18.0.6", "@types/react": "^18.0.15", "@types/react-dom": "^18.0.6", + "@xstate/react": "^3.0.0", "cypress": "^10.3.1", "cypress-visual-regression": "^1.7.0", "lodash": "^4.17.21", @@ -21,7 +22,8 @@ "react-scripts": "5.0.1", "roughjs": "^4.5.2", "typescript": "^4.7.4", - "web-vitals": "^2.1.0" + "web-vitals": "^2.1.0", + "xstate": "^4.32.1" }, "scripts": { "start": "react-scripts start", diff --git a/src/CanvasForSelection.tsx b/src/CanvasForSelection.tsx index 2f4dcd1..da4d5ca 100644 --- a/src/CanvasForSelection.tsx +++ b/src/CanvasForSelection.tsx @@ -12,6 +12,9 @@ import getStroke from 'perfect-freehand' import { CmdButton } from './CmdButton' import { createPointerHandlers } from './selectionToolHelpers/eventHandlers' import { CONFIG } from './config' +import { useMachine } from '@xstate/react' +import { selectionMachine } from './selectionToolHelpers/selectionMachine' +import { getTextElementAtPosition } from './CanvasForText' export function createMoveDataArray({ targetElements, @@ -101,25 +104,6 @@ export function createResizeData({ } } -type TMoveData = - | { - elementType: 'line' | 'rectangle' | 'arrow' | 'image' - elementId: number - pointerOffsetX1: number - pointerOffsetY1: number - } - | { - elementType: 'pencil' - elementId: number - pointerOffsetFromPoints: { offsetX: number; offsetY: number }[] - } - | { - elementType: 'text' - elementId: number - pointerOffsetX1: number - pointerOffsetY1: number - content: string - } type TResizeData = | { elementType: 'line' | 'arrow' @@ -312,24 +296,8 @@ function reducer(prevState: TUiState, action: TAction): TUiState { // * -------------------------- Helpers -------------------------- -function getSelectedElementIdsFromState(uiState: TUiState) { - let elementIds: number[] = [] - - if (uiState.state === 'readyToMove' || uiState.state === 'moving') { - elementIds = uiState.data.map((moveData) => moveData.elementId) - } else if ( - uiState.state === 'readyToResize' || - uiState.state === 'resizing' || - uiState.state === 'singleElementSelected' - ) { - elementIds = [uiState.data.elementId] - } else if (uiState.state === 'areaSelecting') { - elementIds = uiState.data.selectedElementIds - } else if (uiState.state === 'multiElementSelected') { - elementIds = uiState.data.elementIds - } else { - throw new Error(`Cannot extract selected element ids from "${uiState.state}" state`) - } +function getSelectedElementIdsFromState(context: { elementId: number }) { + let elementIds: number[] = [context.elementId] return elementIds } @@ -388,29 +356,19 @@ export function CanvasForSelection({ ) => void }) => void }) { - const [uiState, dispatch] = useReducer(reducer, { state: 'none' }) + const [state, send] = useMachine(selectionMachine, { + actions: { + prepareMove: (context, event) => {}, + }, + }) // useLayoutEffect() in the parent will be ignored in case of a selection tool. // ... Therefore, all canvas drawing logics need to be here instead. useLayoutEffect(() => { // all state we want to draw dashed lines - if ( - uiState.state === 'readyToMove' || - uiState.state === 'moving' || - uiState.state === 'readyToResize' || - uiState.state === 'resizing' || - uiState.state === 'areaSelecting' || - uiState.state === 'singleElementSelected' || - uiState.state === 'multiElementSelected' - ) { - const selectedElementIds = getSelectedElementIdsFromState(uiState) - let extraElements: (TElementData | TAreaSelectData['rectangleSelector'])[] = - getElementsInSnapshot(currentSnapshot, selectedElementIds) - - // also draw rectangle selector (if exist) - if (uiState.state === 'areaSelecting') { - extraElements.push(uiState.data.rectangleSelector) - } + if (state.value === 'move' || state.value === 'singleElementSelected') { + const selectedElementIds = getSelectedElementIdsFromState(state.context) + let extraElements: TElementData[] = getElementsInSnapshot(currentSnapshot, selectedElementIds) // draw dashed selection around all selected elements as an extra drawScene({ @@ -439,42 +397,27 @@ export function CanvasForSelection({ ) // corners - if (uiState.state !== 'multiElementSelected') { - roughCanvas.rectangle(dashTopLeft.x, dashTopLeft.y, dashOffset * 2, dashOffset * 2) - roughCanvas.rectangle( - dashTopLeft.x, - dashBottomRight.y - dashOffset * 2, - dashOffset * 2, - dashOffset * 2 - ) - roughCanvas.rectangle( - dashBottomRight.x - dashOffset * 2, - dashBottomRight.y - dashOffset * 2, - dashOffset * 2, - dashOffset * 2 - ) - roughCanvas.rectangle( - dashBottomRight.x - dashOffset * 2, - dashTopLeft.y, - dashOffset * 2, - dashOffset * 2 - ) - } - - return - } else if (element.type === 'rectangleSelector') { - const context = canvas.getContext('2d') - if (!context) return - context.save() - context.fillStyle = 'rgba(192, 38, 211, 0.2)' - context.fillRect( - element.x1, - element.y1, - element.x2 - element.x1, - element.y2 - element.y1 + roughCanvas.rectangle(dashTopLeft.x, dashTopLeft.y, dashOffset * 2, dashOffset * 2) + roughCanvas.rectangle( + dashTopLeft.x, + dashBottomRight.y - dashOffset * 2, + dashOffset * 2, + dashOffset * 2 ) - context.restore() + roughCanvas.rectangle( + dashBottomRight.x - dashOffset * 2, + dashBottomRight.y - dashOffset * 2, + dashOffset * 2, + dashOffset * 2 + ) + roughCanvas.rectangle( + dashBottomRight.x - dashOffset * 2, + dashTopLeft.y, + dashOffset * 2, + dashOffset * 2 + ) + return } else if (element.type === 'line' || element.type === 'arrow') { const roughCanvas = rough.canvas(canvas, { options: { seed: CONFIG.SEED } }) @@ -497,20 +440,19 @@ export function CanvasForSelection({ ) // start/end of the line - if (uiState.state !== 'multiElementSelected') { - roughCanvas.rectangle( - element.x1, - element.y1 - dashOffset, - dashOffset * 2, - dashOffset * 2 - ) - roughCanvas.rectangle( - element.x2, - element.y2 - dashOffset, - dashOffset * 2, - dashOffset * 2 - ) - } + + roughCanvas.rectangle( + element.x1, + element.y1 - dashOffset, + dashOffset * 2, + dashOffset * 2 + ) + roughCanvas.rectangle( + element.x2, + element.y2 - dashOffset, + dashOffset * 2, + dashOffset * 2 + ) return } else if (element.type === 'pencil') { @@ -549,7 +491,7 @@ export function CanvasForSelection({ // all other state have no extra dashed lines, just normally draw the snapshot drawScene() return - }, [uiState, drawScene, currentSnapshot]) + }, [state, drawScene, currentSnapshot]) // ?? Is there any better approach // Reset uiState when it is holding an element's id that is not being drawn in the canvas. @@ -568,51 +510,59 @@ export function CanvasForSelection({ // 3. Remove `reset` action as a valid action of some uiState (re-consider them one-by-one) // 4. Remove the whole `useEffect` block below useEffect(() => { - if ( - uiState.state === 'readyToMove' || - uiState.state === 'readyToResize' || - uiState.state === 'moving' || - uiState.state === 'resizing' || - uiState.state === 'areaSelecting' || - uiState.state === 'singleElementSelected' || - uiState.state === 'multiElementSelected' - ) { - const selectedElementIds = getSelectedElementIdsFromState(uiState) + if (state.value === 'move' || state.value === 'singleElementSelected') { + const selectedElementIds = getSelectedElementIdsFromState(state.context) const selectedElementsInSnapshot = getElementsInSnapshot(currentSnapshot, selectedElementIds) let hasUnmatchElementInSnapshot = selectedElementIds.length !== selectedElementsInSnapshot.length if (hasUnmatchElementInSnapshot) { - dispatch({ type: validAction[uiState.state].reset }) + send('RESET') } } - }, [uiState, currentSnapshot]) + }, [state, currentSnapshot, send]) const [cursorType, setCursorType] = useState('default') const canvasForMeasureRef = useRef(null) - const { handlePointerDown, handlePointerMove, handlePointerUp } = createPointerHandlers({ - uiState, - dispatch, - currentSnapshot, - getElementInCurrentSnapshot, - commitNewSnapshot, - replaceCurrentSnapshotByReplacingElements, - viewportCoordsToSceneCoords, - setCursorType, - canvasForMeasureRef, - }) + let handlePointerDown, handlePointerMove, handlePointerUp - function handleClickDeleteElement() { - if (uiState.state === 'singleElementSelected') { - commitNewSnapshot({ mode: 'removeElements', elementIds: [uiState.data.elementId] }) - dispatch({ type: 'reset' }) - return - } - if (uiState.state === 'multiElementSelected') { - commitNewSnapshot({ mode: 'removeElements', elementIds: uiState.data.elementIds }) - dispatch({ type: 'reset' }) - return + switch (true) { + case state.value === 'none': { + handlePointerDown = (e: React.PointerEvent) => { + if (!e.isPrimary) return + + const { sceneX, sceneY } = viewportCoordsToSceneCoords({ + viewportX: e.clientX, + viewportY: e.clientY, + }) + + const hitPoint = getLastElementAtPosition({ + elementsSource: currentSnapshot, + xPosition: sceneX, + yPosition: sceneY, + }) + const isHit = hitPoint.pointerPosition !== 'notFound' + + // pointer down does not hit on any elements + if (!isHit) { + return + } + + // pointer down hits on an element + dispatch({ + type: validAction[uiState.state].prepareMove, + data: createMoveDataArray({ + targetElements: [hitPoint.foundLastElement], + pointerX: sceneX, + pointerY: sceneY, + }), + }) + return + } + handlePointerMove = (e: React.PointerEvent) => {} + handlePointerUp = (e: React.PointerEvent) => {} + break } } @@ -628,13 +578,6 @@ export function CanvasForSelection({ For measure text - {/* floating delete button at top-left of the screen */} - {uiState.state === 'singleElementSelected' || uiState.state === 'multiElementSelected' ? ( -
- -
- ) : null} - {renderCanvas({ onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, @@ -644,3 +587,250 @@ export function CanvasForSelection({ ) } + +/* eslint-disable no-extra-label */ +function getLastElementAtPosition({ + elementsSource, + xPosition, + yPosition, +}: { + elementsSource: TElementData[] + xPosition: number + yPosition: number +}): + | { + pointerPosition: 'start' | 'end' | 'tl' | 'tr' | 'bl' | 'br' | 'onLine' | 'inside' + foundLastElement: TElementData + } + | { + pointerPosition: 'notFound' + foundLastElement: undefined + } { + // * ----------------- Helpers -------------------- + + // find the distance between 2 points + function distance(a: { x: number; y: number }, b: { x: number; y: number }) { + return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)) + } + // check if (xPosition, yPosition) is near the specific point + function isNearPoint({ + xPosition, + yPosition, + xPoint, + yPoint, + }: { + xPosition: number + yPosition: number + xPoint: number + yPoint: number + }): boolean { + const THRESHOLD = 8 + return Math.abs(xPosition - xPoint) < THRESHOLD && Math.abs(yPosition - yPoint) < THRESHOLD + } + // check if (xPosition, yPosition) is on the line + function isOnLine({ + xPosition, + yPosition, + x1Line, + y1Line, + x2Line, + y2Line, + threshold = 1, + }: { + xPosition: number + yPosition: number + x1Line: number + y1Line: number + x2Line: number + y2Line: number + threshold?: number + }) { + // a---------------b + // c + const a = { x: x1Line, y: y1Line } + const b = { x: x2Line, y: y2Line } + const c = { x: xPosition, y: yPosition } + const distanceOffset = distance(a, b) - (distance(a, c) + distance(b, c)) + return Math.abs(distanceOffset) < threshold + } + // * ------------------ End ------------------------ + + // in case of not found, these values will be undefined + 'notFound' + let foundLastElement: TElementData | undefined = undefined + let pointerPosition: + | 'start' + | 'end' + | 'tl' + | 'tr' + | 'bl' + | 'br' + | 'onLine' + | 'inside' + | 'notFound' = 'notFound' + + allElementsLoop: for (let i = elementsSource.length - 1; i >= 0; i--) { + const element = elementsSource[i]! + + if (element.type === 'line' || element.type === 'arrow') { + // check if a pointer is at (x1, y1) + if (isNearPoint({ xPosition, yPosition, xPoint: element.x1, yPoint: element.y1 })) { + foundLastElement = element + pointerPosition = 'start' + break allElementsLoop + } + // check if a pointer is at (x2, y2) + else if (isNearPoint({ xPosition, yPosition, xPoint: element.x2, yPoint: element.y2 })) { + foundLastElement = element + pointerPosition = 'end' + break allElementsLoop + } + // check if a pointer is on the line + else if ( + isOnLine({ + xPosition, + yPosition, + x1Line: element.x1, + y1Line: element.y1, + x2Line: element.x2, + y2Line: element.y2, + }) + ) { + foundLastElement = element + pointerPosition = 'onLine' + break allElementsLoop + } + continue allElementsLoop + } else if (element.type === 'rectangle' || element.type === 'image') { + // check if a pointer is at top-left + if (isNearPoint({ xPosition, yPosition, xPoint: element.x1, yPoint: element.y1 })) { + foundLastElement = element + pointerPosition = 'tl' + break allElementsLoop + } + // check if a pointer is at top-right + else if (isNearPoint({ xPosition, yPosition, xPoint: element.x2, yPoint: element.y1 })) { + foundLastElement = element + pointerPosition = 'tr' + break allElementsLoop + } + // check if a pointer is at bottom-right + else if (isNearPoint({ xPosition, yPosition, xPoint: element.x2, yPoint: element.y2 })) { + foundLastElement = element + pointerPosition = 'br' + break allElementsLoop + } + // check if a pointer is at bottom-left + else if (isNearPoint({ xPosition, yPosition, xPoint: element.x1, yPoint: element.y2 })) { + foundLastElement = element + pointerPosition = 'bl' + break allElementsLoop + } + // check if a pointer is on the line of rectangle + else if ( + isOnLine({ + xPosition, + yPosition, + x1Line: element.x1, + y1Line: element.y1, + x2Line: element.x2, + y2Line: element.y1, + }) || + isOnLine({ + xPosition, + yPosition, + x1Line: element.x2, + y1Line: element.y1, + x2Line: element.x2, + y2Line: element.y2, + }) || + isOnLine({ + xPosition, + yPosition, + x1Line: element.x2, + y1Line: element.y2, + x2Line: element.x1, + y2Line: element.y2, + }) || + isOnLine({ + xPosition, + yPosition, + x1Line: element.x1, + y1Line: element.y2, + x2Line: element.x1, + y2Line: element.y1, + }) + ) { + foundLastElement = element + pointerPosition = 'onLine' + break allElementsLoop + } + // TODO: Also check if a pointer is inside the rectangle after we support filled rectangle + else if ( + // only for image element + element.type === 'image' && + element.x1 < xPosition && + xPosition < element.x2 && + element.y1 < yPosition && + yPosition < element.y2 + ) { + foundLastElement = element + pointerPosition = 'inside' + break allElementsLoop + } + + continue allElementsLoop + } else if (element.type === 'pencil') { + pencilElementLoop: for (let i = 0; i < element.points.length - 1; i++) { + const currentPoint = element.points[i] + const nextPoint = element.points[i + 1] + if (!currentPoint || !nextPoint) { + throw new Error('There is a missing point (x,y) within the pencil path!!') + } + if ( + isOnLine({ + xPosition, + yPosition, + x1Line: currentPoint.x, + y1Line: currentPoint.y, + x2Line: nextPoint.x, + y2Line: nextPoint.y, + threshold: 6, + }) + ) { + foundLastElement = element + pointerPosition = 'onLine' + // found an element while looping through points of a single element + break pencilElementLoop + } else { + continue pencilElementLoop + } + } + + // finished looping through points of a single element + // if we found an element(i.e. the last element underneath a pointer), we can stop looping through remaining elements + if (foundLastElement) { + break allElementsLoop + } else { + continue allElementsLoop + } + } else if (element.type === 'text') { + foundLastElement = getTextElementAtPosition({ + elementsSnapshot: [element], + xPosition, + yPosition, + }) + if (foundLastElement) { + pointerPosition = 'inside' + break allElementsLoop + } else { + continue allElementsLoop + } + } + } + + if (!foundLastElement) return { pointerPosition: 'notFound', foundLastElement: undefined } + if (foundLastElement && pointerPosition !== 'notFound') + return { pointerPosition, foundLastElement } + // Should not reach here + throw new Error('Impossible state: Found an element but the pointer position is "notFound"') +} diff --git a/src/selectionToolHelpers/eventHandlers.ts b/src/selectionToolHelpers/eventHandlers.ts index 773c1a5..2483a91 100644 --- a/src/selectionToolHelpers/eventHandlers.ts +++ b/src/selectionToolHelpers/eventHandlers.ts @@ -1,5 +1,6 @@ /* eslint-disable no-extra-label */ import * as React from 'react' +import { State } from 'xstate' import { TCommitNewSnapshotParam, TElementData, @@ -22,252 +23,6 @@ import { singletonThrottle } from '../helpers/throttle' import { moveImageElement, moveRectangleElement } from './moveHelpers' import { resizeImageElement, resizeRectangleElement } from './resizeHelpers' -function getLastElementAtPosition({ - elementsSource, - xPosition, - yPosition, -}: { - elementsSource: TElementData[] - xPosition: number - yPosition: number -}): - | { - pointerPosition: 'start' | 'end' | 'tl' | 'tr' | 'bl' | 'br' | 'onLine' | 'inside' - foundLastElement: TElementData - } - | { - pointerPosition: 'notFound' - foundLastElement: undefined - } { - // * ----------------- Helpers -------------------- - - // find the distance between 2 points - function distance(a: { x: number; y: number }, b: { x: number; y: number }) { - return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)) - } - // check if (xPosition, yPosition) is near the specific point - function isNearPoint({ - xPosition, - yPosition, - xPoint, - yPoint, - }: { - xPosition: number - yPosition: number - xPoint: number - yPoint: number - }): boolean { - const THRESHOLD = 8 - return Math.abs(xPosition - xPoint) < THRESHOLD && Math.abs(yPosition - yPoint) < THRESHOLD - } - // check if (xPosition, yPosition) is on the line - function isOnLine({ - xPosition, - yPosition, - x1Line, - y1Line, - x2Line, - y2Line, - threshold = 1, - }: { - xPosition: number - yPosition: number - x1Line: number - y1Line: number - x2Line: number - y2Line: number - threshold?: number - }) { - // a---------------b - // c - const a = { x: x1Line, y: y1Line } - const b = { x: x2Line, y: y2Line } - const c = { x: xPosition, y: yPosition } - const distanceOffset = distance(a, b) - (distance(a, c) + distance(b, c)) - return Math.abs(distanceOffset) < threshold - } - // * ------------------ End ------------------------ - - // in case of not found, these values will be undefined + 'notFound' - let foundLastElement: TElementData | undefined = undefined - let pointerPosition: - | 'start' - | 'end' - | 'tl' - | 'tr' - | 'bl' - | 'br' - | 'onLine' - | 'inside' - | 'notFound' = 'notFound' - - allElementsLoop: for (let i = elementsSource.length - 1; i >= 0; i--) { - const element = elementsSource[i]! - - if (element.type === 'line' || element.type === 'arrow') { - // check if a pointer is at (x1, y1) - if (isNearPoint({ xPosition, yPosition, xPoint: element.x1, yPoint: element.y1 })) { - foundLastElement = element - pointerPosition = 'start' - break allElementsLoop - } - // check if a pointer is at (x2, y2) - else if (isNearPoint({ xPosition, yPosition, xPoint: element.x2, yPoint: element.y2 })) { - foundLastElement = element - pointerPosition = 'end' - break allElementsLoop - } - // check if a pointer is on the line - else if ( - isOnLine({ - xPosition, - yPosition, - x1Line: element.x1, - y1Line: element.y1, - x2Line: element.x2, - y2Line: element.y2, - }) - ) { - foundLastElement = element - pointerPosition = 'onLine' - break allElementsLoop - } - continue allElementsLoop - } else if (element.type === 'rectangle' || element.type === 'image') { - // check if a pointer is at top-left - if (isNearPoint({ xPosition, yPosition, xPoint: element.x1, yPoint: element.y1 })) { - foundLastElement = element - pointerPosition = 'tl' - break allElementsLoop - } - // check if a pointer is at top-right - else if (isNearPoint({ xPosition, yPosition, xPoint: element.x2, yPoint: element.y1 })) { - foundLastElement = element - pointerPosition = 'tr' - break allElementsLoop - } - // check if a pointer is at bottom-right - else if (isNearPoint({ xPosition, yPosition, xPoint: element.x2, yPoint: element.y2 })) { - foundLastElement = element - pointerPosition = 'br' - break allElementsLoop - } - // check if a pointer is at bottom-left - else if (isNearPoint({ xPosition, yPosition, xPoint: element.x1, yPoint: element.y2 })) { - foundLastElement = element - pointerPosition = 'bl' - break allElementsLoop - } - // check if a pointer is on the line of rectangle - else if ( - isOnLine({ - xPosition, - yPosition, - x1Line: element.x1, - y1Line: element.y1, - x2Line: element.x2, - y2Line: element.y1, - }) || - isOnLine({ - xPosition, - yPosition, - x1Line: element.x2, - y1Line: element.y1, - x2Line: element.x2, - y2Line: element.y2, - }) || - isOnLine({ - xPosition, - yPosition, - x1Line: element.x2, - y1Line: element.y2, - x2Line: element.x1, - y2Line: element.y2, - }) || - isOnLine({ - xPosition, - yPosition, - x1Line: element.x1, - y1Line: element.y2, - x2Line: element.x1, - y2Line: element.y1, - }) - ) { - foundLastElement = element - pointerPosition = 'onLine' - break allElementsLoop - } - // TODO: Also check if a pointer is inside the rectangle after we support filled rectangle - else if ( - // only for image element - element.type === 'image' && - element.x1 < xPosition && - xPosition < element.x2 && - element.y1 < yPosition && - yPosition < element.y2 - ) { - foundLastElement = element - pointerPosition = 'inside' - break allElementsLoop - } - - continue allElementsLoop - } else if (element.type === 'pencil') { - pencilElementLoop: for (let i = 0; i < element.points.length - 1; i++) { - const currentPoint = element.points[i] - const nextPoint = element.points[i + 1] - if (!currentPoint || !nextPoint) { - throw new Error('There is a missing point (x,y) within the pencil path!!') - } - if ( - isOnLine({ - xPosition, - yPosition, - x1Line: currentPoint.x, - y1Line: currentPoint.y, - x2Line: nextPoint.x, - y2Line: nextPoint.y, - threshold: 6, - }) - ) { - foundLastElement = element - pointerPosition = 'onLine' - // found an element while looping through points of a single element - break pencilElementLoop - } else { - continue pencilElementLoop - } - } - - // finished looping through points of a single element - // if we found an element(i.e. the last element underneath a pointer), we can stop looping through remaining elements - if (foundLastElement) { - break allElementsLoop - } else { - continue allElementsLoop - } - } else if (element.type === 'text') { - foundLastElement = getTextElementAtPosition({ - elementsSnapshot: [element], - xPosition, - yPosition, - }) - if (foundLastElement) { - pointerPosition = 'inside' - break allElementsLoop - } else { - continue allElementsLoop - } - } - } - - if (!foundLastElement) return { pointerPosition: 'notFound', foundLastElement: undefined } - if (foundLastElement && pointerPosition !== 'notFound') - return { pointerPosition, foundLastElement } - // Should not reach here - throw new Error('Impossible state: Found an element but the pointer position is "notFound"') -} - function getAllElementIdsInsideRectSelector({ elementsSnapshot, rectSelectorX1, @@ -369,7 +124,7 @@ export function createPointerHandlers({ setCursorType, canvasForMeasureRef, }: { - uiState: TUiState + uiState: State dispatch: React.Dispatch currentSnapshot: TSnapshot getElementInCurrentSnapshot: (elementId: number) => TElementData | undefined diff --git a/src/selectionToolHelpers/selectionMachine.ts b/src/selectionToolHelpers/selectionMachine.ts new file mode 100644 index 0000000..fa879f8 --- /dev/null +++ b/src/selectionToolHelpers/selectionMachine.ts @@ -0,0 +1,93 @@ +import { assign, createMachine } from 'xstate' + +type TContext = { + elementType: string + elementId: number + pointerOffsetX1: number + pointerOffsetY1: number + pointerOffsetFromPoints: { offsetX: number; offsetY: number }[] + content: string +} + +type TMoveData = + | { + elementType: 'line' | 'rectangle' | 'arrow' | 'image' + elementId: number + pointerOffsetX1: number + pointerOffsetY1: number + } + | { + elementType: 'pencil' + elementId: number + pointerOffsetFromPoints: { offsetX: number; offsetY: number }[] + } + | { + elementType: 'text' + elementId: number + pointerOffsetX1: number + pointerOffsetY1: number + content: string + } +type TDOWN_ON_ELEMENT = { type: 'DOWN_ON_ELEMENT' } & TMoveData + +export const selectionMachine = createMachine({ + schema: { + context: {} as TContext, + events: {} as TDOWN_ON_ELEMENT, + }, + id: 'selection', + context: { + elementType: '', + elementId: -1, + pointerOffsetX1: 0, + pointerOffsetY1: 0, + pointerOffsetFromPoints: [], + content: '', + }, + initial: 'none', + states: { + none: { + on: { + DOWN_ON_ELEMENT: { + target: 'move', + actions: [ + 'prepareMove', + assign((context, event) => { + return { + ...context, + ...event, + } + }), + ], + }, + }, + }, + move: { + id: 'move', + initial: 'readyToMove', + states: { + readyToMove: { + on: { + FIRST_MOVE: { target: 'moving', actions: 'startMove' }, + UP_WITHOUT_MOVE: { + target: 'selection.singleElementSelected', + actions: 'selectElement', + }, + }, + }, + moving: { + on: { + NEXT_MOVE: { target: 'moving', actions: 'continueMove' }, + UP_AFTER_MOVE: { target: 'selection.singleElementSelected', actions: 'selectElement' }, + }, + }, + }, + }, + singleElementSelected: { + on: { + DOWN_ON_ELEMENT: { target: 'move', actions: 'prepareMove' }, + RESET: 'none', + }, + }, + }, +}) diff --git a/yarn.lock b/yarn.lock index abbd806..f252d35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2247,6 +2247,14 @@ "@webassemblyjs/ast" "1.11.1" "@xtuc/long" "4.2.2" +"@xstate/react@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@xstate/react/-/react-3.0.0.tgz#888d9a6f128c70b632c18ad55f1f851f6ab092ba" + integrity sha512-KHSCfwtb8gZ7QH2luihvmKYI+0lcdHQOmGNRUxUEs4zVgaJCyd8csCEmwPsudpliLdUmyxX2pzUBojFkINpotw== + dependencies: + use-isomorphic-layout-effect "^1.0.0" + use-sync-external-store "^1.0.0" + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -8989,6 +8997,16 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +use-isomorphic-layout-effect@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" + integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== + +use-sync-external-store@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + utf8-byte-length@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz#f45f150c4c66eee968186505ab93fcbb8ad6bf61" @@ -9515,6 +9533,11 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +xstate@^4.32.1: + version "4.32.1" + resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.32.1.tgz#1a09c808a66072938861a3b4acc5b38460244b70" + integrity sha512-QYUd+3GkXZ8i6qdixnOn28bL3EvA++LONYL/EMWwKlFSh/hiLndJ8YTnz77FDs+JUXcwU7NZJg7qoezoRHc4GQ== + xtend@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" From 580ef07b6ee20bbb8670b27d1d79fe78331ade8b Mon Sep 17 00:00:00 2001 From: Pob <590650@gmail.com> Date: Mon, 1 Aug 2022 23:37:46 +0700 Subject: [PATCH 2/7] WIP --- package.json | 1 + src/CanvasForSelection.tsx | 556 ++++++------- src/index.tsx | 7 + src/selectionToolHelpers/eventHandlers.ts | 827 ------------------- src/selectionToolHelpers/selectionMachine.ts | 32 +- yarn.lock | 12 + 6 files changed, 280 insertions(+), 1155 deletions(-) delete mode 100644 src/selectionToolHelpers/eventHandlers.ts diff --git a/package.json b/package.json index 0f885f9..bd46ae4 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@types/node": "^18.0.6", "@types/react": "^18.0.15", "@types/react-dom": "^18.0.6", + "@xstate/inspect": "^0.7.0", "@xstate/react": "^3.0.0", "cypress": "^10.3.1", "cypress-visual-regression": "^1.7.0", diff --git a/src/CanvasForSelection.tsx b/src/CanvasForSelection.tsx index da4d5ca..d67f51c 100644 --- a/src/CanvasForSelection.tsx +++ b/src/CanvasForSelection.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useLayoutEffect, useEffect, useReducer } from 'react' +import { useState, useRef, useLayoutEffect, useEffect } from 'react' import * as React from 'react' import { getSvgPathFromStroke, @@ -9,314 +9,12 @@ import { } from './App' import rough from 'roughjs/bundled/rough.esm' import getStroke from 'perfect-freehand' -import { CmdButton } from './CmdButton' -import { createPointerHandlers } from './selectionToolHelpers/eventHandlers' import { CONFIG } from './config' import { useMachine } from '@xstate/react' -import { selectionMachine } from './selectionToolHelpers/selectionMachine' -import { getTextElementAtPosition } from './CanvasForText' - -export function createMoveDataArray({ - targetElements, - pointerX, - pointerY, -}: { - targetElements: TElementData[] - pointerX: number - pointerY: number -}): TMoveData[] { - return targetElements.map((targetElement) => { - switch (targetElement.type) { - case 'line': - case 'arrow': - return { - elementType: targetElement.type, - elementId: targetElement.id, - pointerOffsetX1: pointerX - targetElement.x1, - pointerOffsetY1: pointerY - targetElement.y1, - } - case 'rectangle': - case 'image': - return { - elementType: targetElement.type, - elementId: targetElement.id, - pointerOffsetX1: pointerX - targetElement.x1, - pointerOffsetY1: pointerY - targetElement.y1, - } - case 'pencil': - return { - elementType: 'pencil', - elementId: targetElement.id, - pointerOffsetFromPoints: targetElement.points.map((point) => ({ - offsetX: pointerX - point.x, - offsetY: pointerY - point.y, - })), - } - case 'text': - return { - elementType: 'text', - elementId: targetElement.id, - pointerOffsetX1: pointerX - (targetElement.lines[0]?.lineX1 ?? pointerX), - pointerOffsetY1: pointerY - (targetElement.lines[0]?.lineY1 ?? pointerY), - content: targetElement.lines.map(({ lineContent }) => lineContent).join('\n'), - } - default: - throw new Error('Unsupported moving element type') - } - }) -} - -export function createResizeData({ - targetElement, - pointerPosition, -}: { - targetElement: TElementData - pointerPosition: 'start' | 'end' | 'tl' | 'tr' | 'bl' | 'br' -}): TResizeData { - switch (targetElement.type) { - case 'line': - case 'arrow': - if (pointerPosition !== 'start' && pointerPosition !== 'end') { - throw new Error('Impossible pointer position for resizing a linear element') - } - return { - elementType: targetElement.type, - elementId: targetElement.id, - pointerPosition: pointerPosition, - } - case 'rectangle': - case 'image': - if ( - pointerPosition !== 'tl' && - pointerPosition !== 'tr' && - pointerPosition !== 'bl' && - pointerPosition !== 'br' - ) { - throw new Error('Impossible pointer position for resizing a rectangle or image element') - } - return { - elementType: targetElement.type, - elementId: targetElement.id, - pointerPosition: pointerPosition, - } - default: - throw new Error('Unsupported resizing element type') - } -} - -type TResizeData = - | { - elementType: 'line' | 'arrow' - elementId: number - pointerPosition: 'start' | 'end' - } - | { - elementType: 'rectangle' | 'image' - elementId: number - pointerPosition: 'tl' | 'tr' | 'bl' | 'br' - } - -type TAreaSelectData = { - selectedElementIds: number[] - rectangleSelector: { - type: 'rectangleSelector' - x1: number - y1: number - x2: number - y2: number - } -} - -export type TUiState = - | { - state: 'none' - } - | { - state: 'readyToMove' - data: TMoveData[] - } - | { - state: 'moving' - data: TMoveData[] - } - | { - state: 'readyToResize' - data: TResizeData - } - | { - state: 'resizing' - data: TResizeData - } - | { - state: 'areaSelecting' - data: TAreaSelectData - } - | { - state: 'singleElementSelected' - data: { - elementId: number - } - } - | { - state: 'multiElementSelected' - data: { - elementIds: number[] - } - } - -export type TAction = - | { type: 'dragSelect'; data: TAreaSelectData } - | { type: 'prepareMove'; data: TMoveData[] } - | { type: 'startMove'; data: TMoveData[] } - | { type: 'continueMove'; data: TMoveData[] } - | { type: 'prepareResize'; data: TResizeData } - | { type: 'startResize'; data: TResizeData } - | { type: 'continueResize'; data: TResizeData } - | { type: 'selectSingleElement'; data: { elementId: number } } - | { type: 'selectMultipleElements'; data: { elementIds: number[] } } - | { type: 'reset' } - -type TAllActionNames = TAction['type'] - -export const validAction = { - none: { - prepareMove: 'prepareMove', - dragSelect: 'dragSelect', - }, - readyToMove: { - startMove: 'startMove', - selectSingleElement: 'selectSingleElement', - selectMultipleElements: 'selectMultipleElements', - reset: 'reset', - }, - moving: { - continueMove: 'continueMove', - selectSingleElement: 'selectSingleElement', - selectMultipleElements: 'selectMultipleElements', - reset: 'reset', - }, - readyToResize: { - startResize: 'startResize', - selectSingleElement: 'selectSingleElement', - reset: 'reset', - }, - resizing: { - continueResize: 'continueResize', - selectSingleElement: 'selectSingleElement', - reset: 'reset', - }, - areaSelecting: { - dragSelect: 'dragSelect', - selectSingleElement: 'selectSingleElement', - selectMultipleElements: 'selectMultipleElements', - reset: 'reset', - }, - singleElementSelected: { - prepareMove: 'prepareMove', - prepareResize: 'prepareResize', - dragSelect: 'dragSelect', - reset: 'reset', - }, - multiElementSelected: { - prepareMove: 'prepareMove', - dragSelect: 'dragSelect', - reset: 'reset', - }, -} as const - -const mapActionNameToNextStateName = { - dragSelect: 'areaSelecting', - - prepareMove: 'readyToMove', - startMove: 'moving', - continueMove: 'moving', - - prepareResize: 'readyToResize', - startResize: 'resizing', - continueResize: 'resizing', - - selectSingleElement: 'singleElementSelected', - selectMultipleElements: 'multiElementSelected', - - reset: 'none', -} as const - -function reducer(prevState: TUiState, action: TAction): TUiState { - const validActionWithLooserType: { - [CurrentStateName in TUiState['state']]: { [ActionName in TAllActionNames]?: ActionName } - } = validAction - - const isActionValid = validActionWithLooserType[prevState.state][action.type] - if (!isActionValid) { - throw new Error( - `Changing state from "${prevState.state}" by action "${action.type}" is not allowed.` - ) - } - - switch (action.type) { - case 'dragSelect': { - const nextStateName = mapActionNameToNextStateName[action.type] - return { state: nextStateName, data: action.data } - } - case 'prepareMove': { - const nextStateName = mapActionNameToNextStateName[action.type] - return { state: nextStateName, data: action.data } - } - case 'startMove': - case 'continueMove': { - const nextStateName = mapActionNameToNextStateName[action.type] - return { state: nextStateName, data: action.data } - } - case 'prepareResize': { - const nextStateName = mapActionNameToNextStateName[action.type] - return { state: nextStateName, data: action.data } - } - case 'startResize': - case 'continueResize': { - const nextStateName = mapActionNameToNextStateName[action.type] - return { state: nextStateName, data: action.data } - } - case 'selectSingleElement': { - const nextStateName = mapActionNameToNextStateName[action.type] - return { state: nextStateName, data: action.data } - } - case 'selectMultipleElements': { - const nextStateName = mapActionNameToNextStateName[action.type] - return { state: nextStateName, data: action.data } - } - case 'reset': { - const nextStateName = mapActionNameToNextStateName[action.type] - return { state: nextStateName } - } - default: { - throw new Error('Unsupported action type inside the reducer') - } - } -} - -// * -------------------------- Helpers -------------------------- - -function getSelectedElementIdsFromState(context: { elementId: number }) { - let elementIds: number[] = [context.elementId] - - return elementIds -} - -export function getElementsInSnapshot( - elementsSnapshot: TSnapshot, - elementIds: number[] -): TElementData[] { - const elementIdsMap = elementIds.reduce((prev, elementId) => { - prev[elementId] = true - return prev - }, {} as { [elementId: number]: boolean }) - const foundElementsInSnapshot = elementsSnapshot.filter((element) => { - return elementIdsMap[element.id] - }) - return foundElementsInSnapshot -} - -// * -------------------------- End ------------------------------ +import { selectionMachine, TMoveData } from './selectionToolHelpers/selectionMachine' +import { createTextElementWithoutId, getTextElementAtPosition } from './CanvasForText' +import { createLinearElementWithoutId } from './CanvasForLinear' +import { moveImageElement, moveRectangleElement } from './selectionToolHelpers/moveHelpers' export type TCursorType = 'default' | 'move' | 'nesw-resize' | 'nwse-resize' @@ -349,16 +47,95 @@ export function CanvasForSelection({ sceneY: number } drawScene: (extra?: { - elements: (TElementData | TAreaSelectData['rectangleSelector'])[] - drawFn: ( - element: TElementData | TAreaSelectData['rectangleSelector'], - canvas: HTMLCanvasElement - ) => void + elements: TElementData[] + drawFn: (element: TElementData, canvas: HTMLCanvasElement) => void }) => void }) { const [state, send] = useMachine(selectionMachine, { actions: { - prepareMove: (context, event) => {}, + startMove: (context, event) => { + commitNewSnapshot({ mode: 'clone' }) + }, + continueMove: (context, event) => { + if (event.type === 'NEXT_MOVE') { + let replacedMultiElements: TElementData[] = [] + + const movingElementId = context.elementId + + const movingElementInSnapshot = getElementInCurrentSnapshot(movingElementId) + if (!movingElementInSnapshot) { + throw new Error('You are trying to move an non-exist element in the current snapshot!!') + } + if ( + (context.elementType === 'line' && movingElementInSnapshot.type === 'line') || + (context.elementType === 'arrow' && movingElementInSnapshot.type === 'arrow') + ) { + const newX1 = event.sceneX - context.pointerOffsetX1 + const newY1 = event.sceneY - context.pointerOffsetY1 + // keep existing line width + const distanceX = movingElementInSnapshot.x2 - movingElementInSnapshot.x1 + const distanceY = movingElementInSnapshot.y2 - movingElementInSnapshot.y1 + const newElementWithoutId = createLinearElementWithoutId({ + lineType: movingElementInSnapshot.type, + x1: newX1, + y1: newY1, + x2: newX1 + distanceX, + y2: newY1 + distanceY, + }) + replacedMultiElements.push({ ...newElementWithoutId, id: movingElementId }) + } else if ( + context.elementType === 'rectangle' && + movingElementInSnapshot.type === 'rectangle' + ) { + const newX1 = event.sceneX - context.pointerOffsetX1 + const newY1 = event.sceneY - context.pointerOffsetY1 + const newElementWithoutId = moveRectangleElement({ + newX1: newX1, + newY1: newY1, + rectElementToMove: movingElementInSnapshot, + }) + replacedMultiElements.push({ ...newElementWithoutId, id: movingElementId }) + } else if (context.elementType === 'image' && movingElementInSnapshot.type === 'image') { + const newX1 = event.sceneX - context.pointerOffsetX1 + const newY1 = event.sceneY - context.pointerOffsetY1 + const newElementWithoutId = moveImageElement({ + newX1: newX1, + newY1: newY1, + imageElementToMove: movingElementInSnapshot, + }) + replacedMultiElements.push({ ...newElementWithoutId, id: movingElementId }) + } else if ( + context.elementType === 'pencil' && + movingElementInSnapshot.type === 'pencil' + ) { + const newPoints = context.pointerOffsetFromPoints.map(({ offsetX, offsetY }) => ({ + x: event.sceneX - offsetX, + y: event.sceneY - offsetY, + })) + const newElement: TElementData = { + id: movingElementId, + type: 'pencil', + points: newPoints, + } + replacedMultiElements.push(newElement) + } else if (context.elementType === 'text' && movingElementInSnapshot.type === 'text') { + const newElementWithoutId = createTextElementWithoutId({ + canvasForMeasure: canvasForMeasureRef.current, + content: context.content, + isWriting: false, + x1: event.sceneX - context.pointerOffsetX1, + y1: event.sceneY - context.pointerOffsetY1, + }) + replacedMultiElements.push({ ...newElementWithoutId, id: movingElementId }) + } else { + throw new Error( + '1. Mismatch between moving element type and actual element type in the snapshot\n-or-\n2. Unsupported element type for moving' + ) + } + + replaceCurrentSnapshotByReplacingElements({ replacedMultiElements }) + } + }, }, }) @@ -522,10 +299,12 @@ export function CanvasForSelection({ } }, [state, currentSnapshot, send]) - const [cursorType, setCursorType] = useState('default') + const [cursorType] = useState('default') const canvasForMeasureRef = useRef(null) - let handlePointerDown, handlePointerMove, handlePointerUp + let handlePointerDown = (e: React.PointerEvent) => {} + let handlePointerMove = (e: React.PointerEvent) => {} + let handlePointerUp = (e: React.PointerEvent) => {} switch (true) { case state.value === 'none': { @@ -550,14 +329,86 @@ export function CanvasForSelection({ } // pointer down hits on an element - dispatch({ - type: validAction[uiState.state].prepareMove, - data: createMoveDataArray({ - targetElements: [hitPoint.foundLastElement], + send( + 'DOWN_ON_ELEMENT', + createMoveData({ + targetElement: hitPoint.foundLastElement, pointerX: sceneX, pointerY: sceneY, - }), + }) + ) + return + } + handlePointerMove = (e: React.PointerEvent) => {} + handlePointerUp = (e: React.PointerEvent) => {} + break + } + case state.matches({ move: 'readyToMove' }): { + handlePointerDown = (e: React.PointerEvent) => {} + handlePointerMove = (e: React.PointerEvent) => { + if (!e.isPrimary) return + + send('FIRST_MOVE') + return + } + handlePointerUp = (e: React.PointerEvent) => { + if (!e.isPrimary) return + + send('UP_WITHOUT_MOVE') + return + } + break + } + case state.matches({ move: 'moving' }): { + handlePointerDown = (e: React.PointerEvent) => {} + handlePointerMove = (e: React.PointerEvent) => { + if (!e.isPrimary) return + + const { sceneX, sceneY } = viewportCoordsToSceneCoords({ + viewportX: e.clientX, + viewportY: e.clientY, + }) + + send('NEXT_MOVE', { sceneX, sceneY }) + return + } + handlePointerUp = (e: React.PointerEvent) => { + if (!e.isPrimary) return + + send('UP_AFTER_MOVE') + return + } + break + } + case state.value === 'singleElementSelected': { + handlePointerDown = (e: React.PointerEvent) => { + if (!e.isPrimary) return + + const { sceneX, sceneY } = viewportCoordsToSceneCoords({ + viewportX: e.clientX, + viewportY: e.clientY, + }) + const hitPoint = getLastElementAtPosition({ + elementsSource: currentSnapshot, + xPosition: sceneX, + yPosition: sceneY, }) + const isHit = hitPoint.pointerPosition !== 'notFound' + + // pointer down does not hit on any elements + if (!isHit) { + send('RESET') + return + } + + send( + 'DOWN_ON_ELEMENT', + createMoveData({ + targetElement: hitPoint.foundLastElement, + pointerX: sceneX, + pointerY: sceneY, + }) + ) return } handlePointerMove = (e: React.PointerEvent) => {} @@ -834,3 +685,68 @@ function getLastElementAtPosition({ // Should not reach here throw new Error('Impossible state: Found an element but the pointer position is "notFound"') } + +function createMoveData({ + targetElement, + pointerX, + pointerY, +}: { + targetElement: TElementData + pointerX: number + pointerY: number +}): TMoveData { + switch (targetElement.type) { + case 'line': + case 'arrow': + return { + elementType: targetElement.type, + elementId: targetElement.id, + pointerOffsetX1: pointerX - targetElement.x1, + pointerOffsetY1: pointerY - targetElement.y1, + } + case 'rectangle': + case 'image': + return { + elementType: targetElement.type, + elementId: targetElement.id, + pointerOffsetX1: pointerX - targetElement.x1, + pointerOffsetY1: pointerY - targetElement.y1, + } + case 'pencil': + return { + elementType: 'pencil', + elementId: targetElement.id, + pointerOffsetFromPoints: targetElement.points.map((point) => ({ + offsetX: pointerX - point.x, + offsetY: pointerY - point.y, + })), + } + case 'text': + return { + elementType: 'text', + elementId: targetElement.id, + pointerOffsetX1: pointerX - (targetElement.lines[0]?.lineX1 ?? pointerX), + pointerOffsetY1: pointerY - (targetElement.lines[0]?.lineY1 ?? pointerY), + content: targetElement.lines.map(({ lineContent }) => lineContent).join('\n'), + } + default: + throw new Error('Unsupported moving element type') + } +} + +function getSelectedElementIdsFromState(context: { elementId: number }) { + let elementIds: number[] = [context.elementId] + + return elementIds +} + +function getElementsInSnapshot(elementsSnapshot: TSnapshot, elementIds: number[]): TElementData[] { + const elementIdsMap = elementIds.reduce((prev, elementId) => { + prev[elementId] = true + return prev + }, {} as { [elementId: number]: boolean }) + const foundElementsInSnapshot = elementsSnapshot.filter((element) => { + return elementIdsMap[element.id] + }) + return foundElementsInSnapshot +} diff --git a/src/index.tsx b/src/index.tsx index a1d81c7..ec671db 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,6 +3,13 @@ import { createRoot } from 'react-dom/client' import './index.css' import { App } from './App' import { ErrorBoundary } from './ErrorBoundary' +import { inspect } from '@xstate/inspect' + +inspect({ + // options + // url: 'https://stately.ai/viz?inspect', // (default) + iframe: false, // open in new window +}) const root = createRoot(document.getElementById('root')!) root.render( diff --git a/src/selectionToolHelpers/eventHandlers.ts b/src/selectionToolHelpers/eventHandlers.ts deleted file mode 100644 index 2483a91..0000000 --- a/src/selectionToolHelpers/eventHandlers.ts +++ /dev/null @@ -1,827 +0,0 @@ -/* eslint-disable no-extra-label */ -import * as React from 'react' -import { State } from 'xstate' -import { - TCommitNewSnapshotParam, - TElementData, - TReplaceCurrentSnapshotParam, - TSnapshot, -} from '../App' -import { createLinearElementWithoutId } from '../CanvasForLinear' -import { adjustRectangleCoordinates, createRectangleElementWithoutId } from '../CanvasForRect' -import { - createMoveDataArray, - createResizeData, - getElementsInSnapshot, - TAction, - TCursorType, - TUiState, - validAction, -} from '../CanvasForSelection' -import { createTextElementWithoutId, getTextElementAtPosition } from '../CanvasForText' -import { singletonThrottle } from '../helpers/throttle' -import { moveImageElement, moveRectangleElement } from './moveHelpers' -import { resizeImageElement, resizeRectangleElement } from './resizeHelpers' - -function getAllElementIdsInsideRectSelector({ - elementsSnapshot, - rectSelectorX1, - rectSelectorX2, - rectSelectorY1, - rectSelectorY2, -}: { - elementsSnapshot: TElementData[] - rectSelectorX1: number - rectSelectorY1: number - rectSelectorX2: number - rectSelectorY2: number -}) { - const selectedElements = elementsSnapshot.filter((element) => { - const rectMinX = Math.min(rectSelectorX1, rectSelectorX2) - const rectMaxX = Math.max(rectSelectorX1, rectSelectorX2) - const rectMinY = Math.min(rectSelectorY1, rectSelectorY2) - const rectMaxY = Math.max(rectSelectorY1, rectSelectorY2) - if (element.type === 'arrow' || element.type === 'line') { - if ( - rectMinX <= element.x1 && - element.x1 <= rectMaxX && - rectMinY <= element.y1 && - element.y1 <= rectMaxY && - rectMinX <= element.x2 && - element.x2 <= rectMaxX && - rectMinY <= element.y2 && - element.y2 <= rectMaxY - ) { - return true - } - } else if (element.type === 'rectangle' || element.type === 'image') { - const elmMinX = Math.min(element.x1, element.x2) - const elmMaxX = Math.max(element.x1, element.x2) - const elmMinY = Math.min(element.y1, element.y2) - const elmMaxY = Math.max(element.y1, element.y2) - if ( - rectMinX <= elmMinX && - elmMinX <= rectMaxX && - rectMinX <= elmMaxX && - elmMaxX <= rectMaxX && - rectMinY <= elmMinY && - elmMinY <= rectMaxY && - rectMinY <= elmMaxY && - elmMaxY <= rectMaxY - ) { - return true - } - } else if (element.type === 'text') { - const elmMinX = element.lines[0]?.lineX1 ?? -Infinity - const elmMinY = element.lines[0]?.lineY1 ?? -Infinity - const elmMaxY = - (element.lines.at(-1)?.lineY1 ?? Infinity) + (element.lines.at(-1)?.lineHeight ?? Infinity) - let elmMaxX = -Infinity - element.lines.forEach((line) => { - elmMaxX = Math.max(line.lineX1 + line.lineWidth, elmMaxX) - }) - if (elmMaxX === -Infinity) elmMaxX = Infinity - - if ( - rectMinX <= elmMinX && - elmMinX <= rectMaxX && - rectMinX <= elmMaxX && - elmMaxX <= rectMaxX && - rectMinY <= elmMinY && - elmMinY <= rectMaxY && - rectMinY <= elmMaxY && - elmMaxY <= rectMaxY - ) { - return true - } - } else if (element.type === 'pencil') { - const isInside = element.points.every((point) => { - if ( - rectMinX <= point.x && - point.x <= rectMaxX && - rectMinY <= point.y && - point.y <= rectMaxY - ) { - return true - } - return false - }) - return isInside - } - return false - }) - return selectedElements.map((element) => element.id) -} - -export function createPointerHandlers({ - uiState, - dispatch, - currentSnapshot, - getElementInCurrentSnapshot, - commitNewSnapshot, - replaceCurrentSnapshotByReplacingElements, - viewportCoordsToSceneCoords, - setCursorType, - canvasForMeasureRef, -}: { - uiState: State - dispatch: React.Dispatch - currentSnapshot: TSnapshot - getElementInCurrentSnapshot: (elementId: number) => TElementData | undefined - commitNewSnapshot: (arg: TCommitNewSnapshotParam) => number | undefined - replaceCurrentSnapshotByReplacingElements: (arg: TReplaceCurrentSnapshotParam) => void - viewportCoordsToSceneCoords: (arg: { viewportX: number; viewportY: number }) => { - sceneX: number - sceneY: number - } - setCursorType: React.Dispatch> - canvasForMeasureRef: React.MutableRefObject -}) { - function handleCursorUI(e: React.PointerEvent) { - const { sceneX, sceneY } = viewportCoordsToSceneCoords({ - viewportX: e.clientX, - viewportY: e.clientY, - }) - - // cursor UI for all uiState - // TODO: Add state guard and separate cursor between "none" and other state - const { pointerPosition: hoveringPosition } = getLastElementAtPosition({ - elementsSource: currentSnapshot, - xPosition: sceneX, - yPosition: sceneY, - }) - if (hoveringPosition === 'notFound') { - setCursorType('default') - } else if (hoveringPosition === 'onLine' || hoveringPosition === 'inside') { - setCursorType('move') - } else if (hoveringPosition === 'tr' || hoveringPosition === 'bl') { - setCursorType('nesw-resize') - } else if ( - hoveringPosition === 'start' || - hoveringPosition === 'end' || - hoveringPosition === 'tl' || - hoveringPosition === 'br' - ) { - setCursorType('nwse-resize') - } else { - setCursorType('default') - } - } - - switch (uiState.state) { - case 'none': { - return { - handlePointerDown(e: React.PointerEvent) { - if (!e.isPrimary) return - - const { sceneX, sceneY } = viewportCoordsToSceneCoords({ - viewportX: e.clientX, - viewportY: e.clientY, - }) - - const hitPoint = getLastElementAtPosition({ - elementsSource: currentSnapshot, - xPosition: sceneX, - yPosition: sceneY, - }) - const isHit = hitPoint.pointerPosition !== 'notFound' - - // pointer down does not hit on any elements - if (!isHit) { - dispatch({ - type: validAction[uiState.state].dragSelect, - data: { - rectangleSelector: { - type: 'rectangleSelector', - x1: sceneX, - y1: sceneY, - x2: sceneX, - y2: sceneY, - }, - selectedElementIds: [], - }, - }) - return - } - - // pointer down hits on an element - dispatch({ - type: validAction[uiState.state].prepareMove, - data: createMoveDataArray({ - targetElements: [hitPoint.foundLastElement], - pointerX: sceneX, - pointerY: sceneY, - }), - }) - return - }, - handlePointerMove(e: React.PointerEvent) { - if (!e.isPrimary) return - - handleCursorUI(e) - }, - handlePointerUp(e: React.PointerEvent) {}, - } - } - case 'areaSelecting': { - return { - handlePointerDown(e: React.PointerEvent) {}, - handlePointerMove(e: React.PointerEvent) { - if (!e.isPrimary) return - - handleCursorUI(e) - - // can come from either - // - onPointerDown() of previous 'none' || 'singleElementSelected' || 'multiElementSelected' state - // - onPointerMove() of the previous same state: 'areaSelecting' - - const { sceneX, sceneY } = viewportCoordsToSceneCoords({ - viewportX: e.clientX, - viewportY: e.clientY, - }) - - // continue dragging - dispatch({ - type: validAction[uiState.state].dragSelect, - data: { - rectangleSelector: { - type: 'rectangleSelector', - x1: uiState.data.rectangleSelector.x1, - y1: uiState.data.rectangleSelector.y1, - x2: sceneX, - y2: sceneY, - }, - selectedElementIds: getAllElementIdsInsideRectSelector({ - elementsSnapshot: currentSnapshot, - rectSelectorX1: uiState.data.rectangleSelector.x1, - rectSelectorY1: uiState.data.rectangleSelector.y1, - rectSelectorX2: sceneX, - rectSelectorY2: sceneY, - }), - }, - }) - return - }, - handlePointerUp(e: React.PointerEvent) { - if (!e.isPrimary) return - - // should come from onPointerMove() of the previous same state: 'areaSelecting' - - if (uiState.data.selectedElementIds.length >= 2) { - dispatch({ - type: validAction[uiState.state].selectMultipleElements, - data: { - elementIds: uiState.data.selectedElementIds, - }, - }) - return - } - if (uiState.data.selectedElementIds.length === 1) { - dispatch({ - type: validAction[uiState.state].selectSingleElement, - data: { - elementId: uiState.data.selectedElementIds[0]!, - }, - }) - return - } - if (uiState.data.selectedElementIds.length === 0) { - // no element got selected - dispatch({ - type: validAction[uiState.state].reset, - }) - return - } - }, - } - } - case 'readyToMove': { - return { - handlePointerDown(e: React.PointerEvent) {}, - handlePointerMove(e: React.PointerEvent) { - if (!e.isPrimary) return - - // wrap in throttle because the following code need to be called at most once - // https://github.com/pobch/react-diagram/issues/27 - singletonThrottle(() => { - handleCursorUI(e) - - // should come from onPointerDown() of 'none' || 'singleElementSelected' || 'multiElementSelected' state - commitNewSnapshot({ mode: 'clone' }) - dispatch({ type: validAction[uiState.state].startMove, data: [...uiState.data] }) - return - }) - }, - handlePointerUp(e: React.PointerEvent) { - if (!e.isPrimary) return - - // Should come from onPointerDown() of 'none' || 'singleElementSelected' || 'multiElementSelected' state - // ... in the case that onPointerMove() is not triggered at all. - // This means the selected element(s) is not actually move. - // Therefore, don't do anything with history. - - if (uiState.data.length === 0) { - throw new Error('Cannot select any element because the moving element id is missing') - } - if (uiState.data.length === 1) { - dispatch({ - type: validAction[uiState.state].selectSingleElement, - data: { elementId: uiState.data[0]!.elementId }, - }) - return - } - if (uiState.data.length >= 2) { - dispatch({ - type: validAction[uiState.state].selectMultipleElements, - data: { elementIds: uiState.data.map((moveData) => moveData.elementId) }, - }) - return - } - }, - } - } - case 'moving': { - return { - handlePointerDown(e: React.PointerEvent) {}, - handlePointerMove(e: React.PointerEvent) { - if (!e.isPrimary) return - - handleCursorUI(e) - - // can come from either - // - onPointerMove() of previous 'readyToMove' state (when we start to move) - // - onPointerMove() of the previous same state: 'moving' (when we continue to move) - - const { sceneX, sceneY } = viewportCoordsToSceneCoords({ - viewportX: e.clientX, - viewportY: e.clientY, - }) - - // store all moving elements, will be used to replace the current snapshot - let replacedMultiElements: TElementData[] = [] - - // create new element for replacing, one-by-one - uiState.data.forEach((moveData) => { - const movingElementId = moveData.elementId - - const movingElementInSnapshot = getElementInCurrentSnapshot(movingElementId) - if (!movingElementInSnapshot) { - throw new Error( - 'You are trying to move an non-exist element in the current snapshot!!' - ) - } - if ( - (moveData.elementType === 'line' && movingElementInSnapshot.type === 'line') || - (moveData.elementType === 'arrow' && movingElementInSnapshot.type === 'arrow') - ) { - const newX1 = sceneX - moveData.pointerOffsetX1 - const newY1 = sceneY - moveData.pointerOffsetY1 - // keep existing line width - const distanceX = movingElementInSnapshot.x2 - movingElementInSnapshot.x1 - const distanceY = movingElementInSnapshot.y2 - movingElementInSnapshot.y1 - const newElementWithoutId = createLinearElementWithoutId({ - lineType: movingElementInSnapshot.type, - x1: newX1, - y1: newY1, - x2: newX1 + distanceX, - y2: newY1 + distanceY, - }) - replacedMultiElements.push({ ...newElementWithoutId, id: movingElementId }) - // continue forEach loop - return - } else if ( - moveData.elementType === 'rectangle' && - movingElementInSnapshot.type === 'rectangle' - ) { - const newX1 = sceneX - moveData.pointerOffsetX1 - const newY1 = sceneY - moveData.pointerOffsetY1 - const newElementWithoutId = moveRectangleElement({ - newX1: newX1, - newY1: newY1, - rectElementToMove: movingElementInSnapshot, - }) - replacedMultiElements.push({ ...newElementWithoutId, id: movingElementId }) - // continue forEach loop - return - } else if ( - moveData.elementType === 'image' && - movingElementInSnapshot.type === 'image' - ) { - const newX1 = sceneX - moveData.pointerOffsetX1 - const newY1 = sceneY - moveData.pointerOffsetY1 - const newElementWithoutId = moveImageElement({ - newX1: newX1, - newY1: newY1, - imageElementToMove: movingElementInSnapshot, - }) - replacedMultiElements.push({ ...newElementWithoutId, id: movingElementId }) - // continue forEach loop - return - } else if ( - moveData.elementType === 'pencil' && - movingElementInSnapshot.type === 'pencil' - ) { - const newPoints = moveData.pointerOffsetFromPoints.map(({ offsetX, offsetY }) => ({ - x: sceneX - offsetX, - y: sceneY - offsetY, - })) - const newElement: TElementData = { - id: movingElementId, - type: 'pencil', - points: newPoints, - } - replacedMultiElements.push(newElement) - // continue forEach loop - return - } else if (moveData.elementType === 'text' && movingElementInSnapshot.type === 'text') { - const newElementWithoutId = createTextElementWithoutId({ - canvasForMeasure: canvasForMeasureRef.current, - content: moveData.content, - isWriting: false, - x1: sceneX - moveData.pointerOffsetX1, - y1: sceneY - moveData.pointerOffsetY1, - }) - replacedMultiElements.push({ ...newElementWithoutId, id: movingElementId }) - // continue forEach loop - return - } else { - throw new Error( - '1. Mismatch between moving element type and actual element type in the snapshot\n-or-\n2. Unsupported element type for moving' - ) - } - }) - - replaceCurrentSnapshotByReplacingElements({ replacedMultiElements }) - dispatch({ type: validAction[uiState.state].continueMove, data: [...uiState.data] }) - return - }, - handlePointerUp(e: React.PointerEvent) { - if (!e.isPrimary) return - // reset the throttle timer that comes from the previous state's onPointerMove() - singletonThrottle.cancel() - - // should come from onPointerMove() of the same previous same state: 'moving' - if (uiState.data.length === 0) { - throw new Error( - 'Cannot finish moving an element because the moving element id is missing' - ) - } - if (uiState.data.length === 1) { - dispatch({ - type: validAction[uiState.state].selectSingleElement, - data: { elementId: uiState.data[0]!.elementId }, - }) - return - } - if (uiState.data.length >= 2) { - dispatch({ - type: validAction[uiState.state].selectMultipleElements, - data: { elementIds: uiState.data.map((moveData) => moveData.elementId) }, - }) - return - } - }, - } - } - case 'readyToResize': { - return { - handlePointerDown(e: React.PointerEvent) {}, - handlePointerMove(e: React.PointerEvent) { - if (!e.isPrimary) return - - // wrap in throttle because the following code need to be called at most once - // https://github.com/pobch/react-diagram/issues/27 - singletonThrottle(() => { - handleCursorUI(e) - - // should come from onPointerDown() of 'singleElementSelected' state - commitNewSnapshot({ mode: 'clone' }) - dispatch({ type: validAction[uiState.state].startResize, data: { ...uiState.data } }) - return - }) - }, - handlePointerUp(e: React.PointerEvent) { - if (!e.isPrimary) return - - // Should come from onPointerDown() of 'singleElementSelected' state - // ... in the case that onPointerMove() is not triggered at all. - // This means the selected element is not actually resize. - // Therefore, don't do anything with history. - - dispatch({ - type: validAction[uiState.state].selectSingleElement, - data: { elementId: uiState.data.elementId }, - }) - return - }, - } - } - case 'resizing': { - return { - handlePointerDown(e: React.PointerEvent) {}, - handlePointerMove(e: React.PointerEvent) { - if (!e.isPrimary) return - - handleCursorUI(e) - - // can come from either - // - onPointerMove() of previous 'readyToResize' state (when we start to resize) - // - onPointerMove() of the previous same state: 'resizing' (when we continue to resize) - - const { sceneX, sceneY } = viewportCoordsToSceneCoords({ - viewportX: e.clientX, - viewportY: e.clientY, - }) - - // replace this specific element - const resizingElementId = uiState.data.elementId - - const resizingElement = getElementInCurrentSnapshot(resizingElementId) - if (!resizingElement) { - throw new Error( - 'You are trying to resize an non-exist element in the current snapshot!!' - ) - } - if ( - (uiState.data.elementType === 'line' && resizingElement.type === 'line') || - (uiState.data.elementType === 'arrow' && resizingElement.type === 'arrow') - ) { - if (uiState.data.pointerPosition === 'start') { - const newElementWithoutId = createLinearElementWithoutId({ - lineType: resizingElement.type, - x1: sceneX, - y1: sceneY, - x2: resizingElement.x2, - y2: resizingElement.y2, - }) - replaceCurrentSnapshotByReplacingElements({ - replacedElement: { ...newElementWithoutId, id: resizingElementId }, - }) - dispatch({ - type: validAction[uiState.state].continueResize, - data: { ...uiState.data }, - }) - return - } else if (uiState.data.pointerPosition === 'end') { - const newElementWithoutId = createLinearElementWithoutId({ - lineType: resizingElement.type, - x1: resizingElement.x1, - y1: resizingElement.y1, - x2: sceneX, - y2: sceneY, - }) - replaceCurrentSnapshotByReplacingElements({ - replacedElement: { ...newElementWithoutId, id: resizingElementId }, - }) - dispatch({ - type: validAction[uiState.state].continueResize, - data: { ...uiState.data }, - }) - return - } - // should not reach here - throw new Error( - 'While resizing a line or arrow, the pointer position is not at either end of the line.' - ) - } else if ( - uiState.data.elementType === 'rectangle' && - resizingElement.type === 'rectangle' - ) { - const newElementWithoutId = resizeRectangleElement({ - newPointerPosition: { x: sceneX, y: sceneY }, - pointerStartedAt: uiState.data.pointerPosition, - rectElementToResize: resizingElement, - }) - replaceCurrentSnapshotByReplacingElements({ - replacedElement: { ...newElementWithoutId, id: resizingElementId }, - }) - dispatch({ - type: validAction[uiState.state].continueResize, - data: { ...uiState.data }, - }) - return - } else if (uiState.data.elementType === 'image' && resizingElement.type === 'image') { - const newElementWithoutId = resizeImageElement({ - newPointerPosition: { x: sceneX, y: sceneY }, - pointerStartedAt: uiState.data.pointerPosition, - imageElementToResize: resizingElement, - }) - replaceCurrentSnapshotByReplacingElements({ - replacedElement: { ...newElementWithoutId, id: resizingElementId }, - }) - dispatch({ - type: validAction[uiState.state].continueResize, - data: { ...uiState.data }, - }) - return - } else { - throw new Error( - '1. Mismatch between resizing element type and actual element type in the snapshot\n-or-\n2. Unsupported element type for resizing' - ) - } - }, - handlePointerUp(e: React.PointerEvent) { - if (!e.isPrimary) return - // reset the throttle timer that comes from the previous state's onPointerMove() - singletonThrottle.cancel() - - // should come from onPointerMove() of the same previous same state: 'resizing' - - // adjust coordinates to handle the case when resizing flips the rectangle - if (uiState.data.elementType === 'rectangle') { - const resizingElementId = uiState.data.elementId - const resizingElement = getElementInCurrentSnapshot(resizingElementId) - if (!resizingElement || resizingElement.type !== 'rectangle') { - throw new Error('The resizing element is not a "rectangle" element') - } - const { newX1, newX2, newY1, newY2 } = adjustRectangleCoordinates(resizingElement) - const newElementWithoutId = createRectangleElementWithoutId({ - x1: newX1, - y1: newY1, - width: newX2 - newX1, - height: newY2 - newY1, - }) - replaceCurrentSnapshotByReplacingElements({ - replacedElement: { ...newElementWithoutId, id: resizingElementId }, - }) - dispatch({ - type: validAction[uiState.state].selectSingleElement, - data: { elementId: resizingElementId }, - }) - return - } - - dispatch({ - type: validAction[uiState.state].selectSingleElement, - data: { elementId: uiState.data.elementId }, - }) - return - }, - } - } - case 'singleElementSelected': { - return { - handlePointerDown(e: React.PointerEvent) { - if (!e.isPrimary) return - - const { sceneX, sceneY } = viewportCoordsToSceneCoords({ - viewportX: e.clientX, - viewportY: e.clientY, - }) - const hitPoint = getLastElementAtPosition({ - elementsSource: currentSnapshot, - xPosition: sceneX, - yPosition: sceneY, - }) - const isHit = hitPoint.pointerPosition !== 'notFound' - - // pointer down does not hit on any elements - if (!isHit) { - dispatch({ - type: validAction[uiState.state].dragSelect, - data: { - rectangleSelector: { - type: 'rectangleSelector', - x1: sceneX, - y1: sceneY, - x2: sceneX, - y2: sceneY, - }, - selectedElementIds: [], - }, - }) - return - } - // pointer down hits a different element than the current selected element - const isHitOnUnselectedElement = hitPoint.foundLastElement.id !== uiState.data.elementId - if (isHitOnUnselectedElement) { - // allow to move only - dispatch({ - type: validAction[uiState.state].prepareMove, - data: createMoveDataArray({ - targetElements: [hitPoint.foundLastElement], - pointerX: sceneX, - pointerY: sceneY, - }), - }) - return - } - - // pointer down hits on the current selected element - // we allow to either move or resize an element - // ... so, we need to check which part of the element was clicked - if (hitPoint.pointerPosition === 'onLine' || hitPoint.pointerPosition === 'inside') { - dispatch({ - type: validAction[uiState.state].prepareMove, - data: createMoveDataArray({ - targetElements: [hitPoint.foundLastElement], - pointerX: sceneX, - pointerY: sceneY, - }), - }) - } else if ( - hitPoint.pointerPosition === 'start' || - hitPoint.pointerPosition === 'end' || - hitPoint.pointerPosition === 'tl' || - hitPoint.pointerPosition === 'tr' || - hitPoint.pointerPosition === 'br' || - hitPoint.pointerPosition === 'bl' - ) { - dispatch({ - type: validAction[uiState.state].prepareResize, - data: createResizeData({ - targetElement: hitPoint.foundLastElement, - pointerPosition: hitPoint.pointerPosition, - }), - }) - } else { - throw new Error(`${hitPoint.pointerPosition} pointer position is not supported`) - } - return - }, - handlePointerMove(e: React.PointerEvent) { - if (!e.isPrimary) return - - handleCursorUI(e) - }, - handlePointerUp(e: React.PointerEvent) {}, - } - } - case 'multiElementSelected': { - return { - handlePointerDown(e: React.PointerEvent) { - if (!e.isPrimary) return - - const { sceneX, sceneY } = viewportCoordsToSceneCoords({ - viewportX: e.clientX, - viewportY: e.clientY, - }) - const hitPoint = getLastElementAtPosition({ - elementsSource: currentSnapshot, - xPosition: sceneX, - yPosition: sceneY, - }) - const isHit = hitPoint.pointerPosition !== 'notFound' - - // pointer down does not hit on any elements - if (!isHit) { - dispatch({ - type: validAction[uiState.state].dragSelect, - data: { - rectangleSelector: { - type: 'rectangleSelector', - x1: sceneX, - y1: sceneY, - x2: sceneX, - y2: sceneY, - }, - selectedElementIds: [], - }, - }) - return - } - - // find out if a pointer down hits on one of the current selected elements or not - const currentSelectedElements = getElementsInSnapshot( - currentSnapshot, - uiState.data.elementIds - ) - const isPointerHitOneOfSelectedElements = currentSelectedElements.some( - (selectedElement) => selectedElement.id === hitPoint.foundLastElement.id - ) - - // pointer down hits on the current selected elements - // we allow to move only - if (isPointerHitOneOfSelectedElements) { - dispatch({ - type: validAction[uiState.state].prepareMove, - data: createMoveDataArray({ - targetElements: currentSelectedElements, - pointerX: sceneX, - pointerY: sceneY, - }), - }) - return - } - // pointer down hits on a different element than the current selected elements - // we reset state - else { - dispatch({ - type: validAction[uiState.state].reset, - }) - return - } - }, - handlePointerMove(e: React.PointerEvent) { - if (!e.isPrimary) return - - handleCursorUI(e) - }, - handlePointerUp(e: React.PointerEvent) {}, - } - } - default: { - throw new Error('Cannot generate pointer handlers based on the current UI state') - } - } -} diff --git a/src/selectionToolHelpers/selectionMachine.ts b/src/selectionToolHelpers/selectionMachine.ts index fa879f8..e5122d2 100644 --- a/src/selectionToolHelpers/selectionMachine.ts +++ b/src/selectionToolHelpers/selectionMachine.ts @@ -9,7 +9,7 @@ type TContext = { content: string } -type TMoveData = +export type TMoveData = | { elementType: 'line' | 'rectangle' | 'arrow' | 'image' elementId: number @@ -28,12 +28,20 @@ type TMoveData = pointerOffsetY1: number content: string } -type TDOWN_ON_ELEMENT = { type: 'DOWN_ON_ELEMENT' } & TMoveData +type TDOWN_ON_ELEMENT = { + type: 'DOWN_ON_ELEMENT' +} & TMoveData export const selectionMachine = createMachine({ schema: { context: {} as TContext, - events: {} as TDOWN_ON_ELEMENT, + events: {} as + | TDOWN_ON_ELEMENT + | { type: 'FIRST_MOVE' } + | { type: 'UP_WITHOUT_MOVE' } + | { type: 'NEXT_MOVE'; sceneX: number; sceneY: number } + | { type: 'UP_AFTER_MOVE' } + | { type: 'RESET' }, }, id: 'selection', context: { @@ -51,7 +59,6 @@ export const selectionMachine = createMachine({ DOWN_ON_ELEMENT: { target: 'move', actions: [ - 'prepareMove', assign((context, event) => { return { ...context, @@ -70,22 +77,31 @@ export const selectionMachine = createMachine({ on: { FIRST_MOVE: { target: 'moving', actions: 'startMove' }, UP_WITHOUT_MOVE: { - target: 'selection.singleElementSelected', - actions: 'selectElement', + target: '#selection.singleElementSelected', }, }, }, moving: { on: { NEXT_MOVE: { target: 'moving', actions: 'continueMove' }, - UP_AFTER_MOVE: { target: 'selection.singleElementSelected', actions: 'selectElement' }, + UP_AFTER_MOVE: { target: '#selection.singleElementSelected' }, }, }, }, }, singleElementSelected: { on: { - DOWN_ON_ELEMENT: { target: 'move', actions: 'prepareMove' }, + DOWN_ON_ELEMENT: { + target: 'move', + actions: [ + assign((context, event) => { + return { + ...context, + ...event, + } + }), + ], + }, RESET: 'none', }, }, diff --git a/yarn.lock b/yarn.lock index f252d35..e1d8af6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2247,6 +2247,13 @@ "@webassemblyjs/ast" "1.11.1" "@xtuc/long" "4.2.2" +"@xstate/inspect@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@xstate/inspect/-/inspect-0.7.0.tgz#0e3011d0fb8eca6d68f06a7c384ab1390801e176" + integrity sha512-3wrTf8TfBYprH1gBFdxmOQUBDpBazlICWvGdFzr8IHFL4MbiexEZdAsL2QC/WAmW9BqNYTWTwgfbvKHKg+FrlA== + dependencies: + fast-safe-stringify "^2.1.1" + "@xstate/react@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@xstate/react/-/react-3.0.0.tgz#888d9a6f128c70b632c18ad55f1f851f6ab092ba" @@ -4491,6 +4498,11 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= +fast-safe-stringify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" + integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== + fastq@^1.6.0: version "1.13.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c" From ae3411110b0c057b6bcdad0526e7de27675f5ee9 Mon Sep 17 00:00:00 2001 From: Pob <590650@gmail.com> Date: Mon, 1 Aug 2022 23:58:03 +0700 Subject: [PATCH 3/7] WIP --- src/index.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index ec671db..d383aa1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,13 +3,12 @@ import { createRoot } from 'react-dom/client' import './index.css' import { App } from './App' import { ErrorBoundary } from './ErrorBoundary' -import { inspect } from '@xstate/inspect' - -inspect({ - // options - // url: 'https://stately.ai/viz?inspect', // (default) - iframe: false, // open in new window -}) +// import { inspect } from '@xstate/inspect' +// inspect({ +// // options +// // url: 'https://stately.ai/viz?inspect', // (default) +// iframe: false, // open in new window +// }) const root = createRoot(document.getElementById('root')!) root.render( From fa76c145b454800a0b9ffd801a55673c905de568 Mon Sep 17 00:00:00 2001 From: Pob <590650@gmail.com> Date: Tue, 2 Aug 2022 00:20:23 +0700 Subject: [PATCH 4/7] WIP --- src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 44e9870..05d936b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -114,9 +114,9 @@ function useHistory() { } setHistory((prevHistory) => { const newHistory = [...prevHistory.slice(0, currentIndex + 1), newSnapshot] - setCurrentIndex(newHistory.length - 1) return newHistory }) + setCurrentIndex((prevIndex) => prevIndex + 1) // for "addElement" mode, we also return new element's id if (options.mode === 'addElement') { From 1e854d351a08df3744f6d541349ce5fb0303ba86 Mon Sep 17 00:00:00 2001 From: Pob <590650@gmail.com> Date: Tue, 2 Aug 2022 11:19:03 +0700 Subject: [PATCH 5/7] WIP --- src/App.tsx | 3 ++- src/CanvasForSelection.tsx | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 05d936b..beb64af 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -72,6 +72,7 @@ function useHistory() { const [history, setHistory] = useState([[]]) const [currentIndex, setCurrentIndex] = useState(0) const currentSnapshot = history[currentIndex] + console.log('currentIndex', currentIndex) if (!currentSnapshot) { throw new Error('The whole current snapshot is not exist in this point of history!!') } @@ -116,7 +117,7 @@ function useHistory() { const newHistory = [...prevHistory.slice(0, currentIndex + 1), newSnapshot] return newHistory }) - setCurrentIndex((prevIndex) => prevIndex + 1) + setCurrentIndex(currentIndex + 1) // for "addElement" mode, we also return new element's id if (options.mode === 'addElement') { diff --git a/src/CanvasForSelection.tsx b/src/CanvasForSelection.tsx index d67f51c..b41a355 100644 --- a/src/CanvasForSelection.tsx +++ b/src/CanvasForSelection.tsx @@ -54,9 +54,11 @@ export function CanvasForSelection({ const [state, send] = useMachine(selectionMachine, { actions: { startMove: (context, event) => { + console.log('triggered commitNewSnapshot') commitNewSnapshot({ mode: 'clone' }) }, continueMove: (context, event) => { + console.log('triggered continueMove') if (event.type === 'NEXT_MOVE') { let replacedMultiElements: TElementData[] = [] @@ -132,7 +134,7 @@ export function CanvasForSelection({ '1. Mismatch between moving element type and actual element type in the snapshot\n-or-\n2. Unsupported element type for moving' ) } - + console.log('triggerd replaceCurrentSnapshotByReplacingElements') replaceCurrentSnapshotByReplacingElements({ replacedMultiElements }) } }, From 9d4723cead699282c5f68afe429185544ed64be5 Mon Sep 17 00:00:00 2001 From: Pob <590650@gmail.com> Date: Tue, 2 Aug 2022 13:05:39 +0700 Subject: [PATCH 6/7] flushSync --- src/CanvasForSelection.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/CanvasForSelection.tsx b/src/CanvasForSelection.tsx index b41a355..a9cee6c 100644 --- a/src/CanvasForSelection.tsx +++ b/src/CanvasForSelection.tsx @@ -15,6 +15,7 @@ import { selectionMachine, TMoveData } from './selectionToolHelpers/selectionMac import { createTextElementWithoutId, getTextElementAtPosition } from './CanvasForText' import { createLinearElementWithoutId } from './CanvasForLinear' import { moveImageElement, moveRectangleElement } from './selectionToolHelpers/moveHelpers' +import { flushSync } from 'react-dom' export type TCursorType = 'default' | 'move' | 'nesw-resize' | 'nwse-resize' @@ -55,7 +56,9 @@ export function CanvasForSelection({ actions: { startMove: (context, event) => { console.log('triggered commitNewSnapshot') - commitNewSnapshot({ mode: 'clone' }) + flushSync(() => { + commitNewSnapshot({ mode: 'clone' }) + }) }, continueMove: (context, event) => { console.log('triggered continueMove') From 55123d7ed88944f631f747fc3ada719ee0bed6d1 Mon Sep 17 00:00:00 2001 From: Pob <590650@gmail.com> Date: Tue, 2 Aug 2022 16:59:15 +0700 Subject: [PATCH 7/7] remove console.log --- src/App.tsx | 1 - src/CanvasForSelection.tsx | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index beb64af..0b6e0f6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -72,7 +72,6 @@ function useHistory() { const [history, setHistory] = useState([[]]) const [currentIndex, setCurrentIndex] = useState(0) const currentSnapshot = history[currentIndex] - console.log('currentIndex', currentIndex) if (!currentSnapshot) { throw new Error('The whole current snapshot is not exist in this point of history!!') } diff --git a/src/CanvasForSelection.tsx b/src/CanvasForSelection.tsx index a9cee6c..b16a485 100644 --- a/src/CanvasForSelection.tsx +++ b/src/CanvasForSelection.tsx @@ -55,13 +55,11 @@ export function CanvasForSelection({ const [state, send] = useMachine(selectionMachine, { actions: { startMove: (context, event) => { - console.log('triggered commitNewSnapshot') flushSync(() => { commitNewSnapshot({ mode: 'clone' }) }) }, continueMove: (context, event) => { - console.log('triggered continueMove') if (event.type === 'NEXT_MOVE') { let replacedMultiElements: TElementData[] = [] @@ -137,7 +135,7 @@ export function CanvasForSelection({ '1. Mismatch between moving element type and actual element type in the snapshot\n-or-\n2. Unsupported element type for moving' ) } - console.log('triggerd replaceCurrentSnapshotByReplacingElements') + replaceCurrentSnapshotByReplacingElements({ replacedMultiElements }) } },