Improves undo/redo, fixes pinching and multitouch

This commit is contained in:
Steve Ruiz 2021-05-30 14:14:35 +01:00
parent bc6f5cf5b7
commit 76a4ccdfcb
15 changed files with 366 additions and 288 deletions

View file

@ -1,27 +1,29 @@
import { useCallback, useRef } from "react" import { useCallback, useRef } from 'react'
import state, { useSelector } from "state" import state, { useSelector } from 'state'
import inputs from "state/inputs" import inputs from 'state/inputs'
import styled from "styles" import styled from 'styles'
import { getPage } from "utils/utils" import { getPage } from 'utils/utils'
function handlePointerDown(e: React.PointerEvent<SVGRectElement>) { function handlePointerDown(e: React.PointerEvent<SVGRectElement>) {
if (e.buttons !== 1) return if (e.buttons !== 1) return
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation() e.stopPropagation()
e.currentTarget.setPointerCapture(e.pointerId) e.currentTarget.setPointerCapture(e.pointerId)
state.send("POINTED_BOUNDS", inputs.pointerDown(e, "bounds")) state.send('POINTED_BOUNDS', inputs.pointerDown(e, 'bounds'))
} }
function handlePointerUp(e: React.PointerEvent<SVGRectElement>) { function handlePointerUp(e: React.PointerEvent<SVGRectElement>) {
if (e.buttons !== 1) return if (e.buttons !== 1) return
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation() e.stopPropagation()
e.currentTarget.releasePointerCapture(e.pointerId) e.currentTarget.releasePointerCapture(e.pointerId)
state.send("STOPPED_POINTING", inputs.pointerUp(e)) state.send('STOPPED_POINTING', inputs.pointerUp(e))
} }
export default function BoundsBg() { export default function BoundsBg() {
const rBounds = useRef<SVGRectElement>(null) const rBounds = useRef<SVGRectElement>(null)
const bounds = useSelector((state) => state.values.selectedBounds) const bounds = useSelector((state) => state.values.selectedBounds)
const isSelecting = useSelector((s) => s.isIn("selecting")) const isSelecting = useSelector((s) => s.isIn('selecting'))
const rotation = useSelector((s) => { const rotation = useSelector((s) => {
if (s.data.selectedIds.size === 1) { if (s.data.selectedIds.size === 1) {
const { shapes } = getPage(s.data) const { shapes } = getPage(s.data)
@ -53,6 +55,6 @@ export default function BoundsBg() {
) )
} }
const StyledBoundsBg = styled("rect", { const StyledBoundsBg = styled('rect', {
fill: "$boundsBg", fill: '$boundsBg',
}) })

View file

@ -21,15 +21,26 @@ export default function Canvas() {
const isReady = useSelector((s) => s.isIn('ready')) const isReady = useSelector((s) => s.isIn('ready'))
const handlePointerDown = useCallback((e: React.PointerEvent) => { const handlePointerDown = useCallback((e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
rCanvas.current.setPointerCapture(e.pointerId) rCanvas.current.setPointerCapture(e.pointerId)
state.send('POINTED_CANVAS', inputs.pointerDown(e, 'canvas')) state.send('POINTED_CANVAS', inputs.pointerDown(e, 'canvas'))
}, []) }, [])
const handleTouchStart = useCallback((e: React.TouchEvent) => {
if (e.touches.length === 2) {
state.send('TOUCH_UNDO')
}
}, [])
const handlePointerMove = useCallback((e: React.PointerEvent) => { const handlePointerMove = useCallback((e: React.PointerEvent) => {
state.send('MOVED_POINTER', inputs.pointerMove(e)) if (!inputs.canAccept(e.pointerId)) return
if (inputs.canAccept(e.pointerId)) {
state.send('MOVED_POINTER', inputs.pointerMove(e))
}
}, []) }, [])
const handlePointerUp = useCallback((e: React.PointerEvent) => { const handlePointerUp = useCallback((e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
rCanvas.current.releasePointerCapture(e.pointerId) rCanvas.current.releasePointerCapture(e.pointerId)
state.send('STOPPED_POINTING', { id: 'canvas', ...inputs.pointerUp(e) }) state.send('STOPPED_POINTING', { id: 'canvas', ...inputs.pointerUp(e) })
}, []) }, [])
@ -41,14 +52,15 @@ export default function Canvas() {
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onTouchStart={handleTouchStart}
> >
<Defs /> <Defs />
{isReady && ( {isReady && (
<g ref={rGroup}> <g ref={rGroup}>
<BoundsBg /> <BoundsBg />
<Page /> <Page />
<Bounds />
<Selected /> <Selected />
<Bounds />
<Brush /> <Brush />
</g> </g>
)} )}

View file

@ -26,12 +26,11 @@ export const IconButton = styled('button', {
'& > svg': { '& > svg': {
height: '16px', height: '16px',
width: '16px', width: '16px',
// strokeWidth: '2px',
// stroke: '$text',
}, },
variants: { variants: {
size: { size: {
small: {},
medium: { medium: {
height: 44, height: 44,
width: 44, width: 44,

View file

@ -1,48 +1,62 @@
import { useStateDesigner } from "@state-designer/react" import { useStateDesigner } from '@state-designer/react'
import state from "state" import state from 'state'
import styled from "styles" import styled from 'styles'
import { useRef } from "react" import { useRef } from 'react'
export default function StatusBar() { export default function StatusBar() {
const local = useStateDesigner(state) const local = useStateDesigner(state)
const { count, time } = useRenderCount() const { count, time } = useRenderCount()
const active = local.active.slice(1).map((s) => s.split("root.")[1]) const active = local.active.slice(1).map((s) => s.split('root.')[1])
const log = local.log[0] const log = local.log[0]
return ( return (
<StatusBarContainer> <StatusBarContainer
<Section>{active.join(" | ")}</Section> size={{
'@sm': 'small',
}}
>
<Section>{active.join(' | ')}</Section>
<Section>| {log}</Section> <Section>| {log}</Section>
<Section title="Renders | Time"> {/* <Section
{count} | {time.toString().padStart(3, "0")} title="Renders | Time"
</Section> >
{count} | {time.toString().padStart(3, '0')}
</Section> */}
</StatusBarContainer> </StatusBarContainer>
) )
} }
const StatusBarContainer = styled("div", { const StatusBarContainer = styled('div', {
position: "absolute", position: 'absolute',
bottom: 0, bottom: 0,
left: 0, left: 0,
width: "100%", width: '100%',
height: 40, height: 40,
userSelect: "none", userSelect: 'none',
borderTop: "1px solid black", borderTop: '1px solid black',
gridArea: "status", gridArea: 'status',
display: "grid", display: 'grid',
gridTemplateColumns: "auto 1fr auto", gridTemplateColumns: 'auto 1fr auto',
alignItems: "center", alignItems: 'center',
backgroundColor: "white", backgroundColor: 'white',
gap: 8, gap: 8,
fontSize: "$1", fontSize: '$0',
padding: "0 16px", padding: '0 16px',
zIndex: 200, zIndex: 200,
variants: {
size: {
small: {
fontSize: '$1',
},
},
},
}) })
const Section = styled("div", { const Section = styled('div', {
whiteSpace: "nowrap", whiteSpace: 'nowrap',
overflow: "hidden", overflow: 'hidden',
}) })
function useRenderCount() { function useRenderCount() {

View file

@ -52,101 +52,117 @@ export default function ToolsPanel() {
return ( return (
<OuterContainer> <OuterContainer>
<Zoom /> <Zoom />
<Container> <Flex>
<IconButton <Container>
name="select" <IconButton
size="large" name="select"
onClick={selectSelectTool} size={{ '@sm': 'small', '@md': 'large' }}
isActive={activeTool === 'select'} onClick={selectSelectTool}
> isActive={activeTool === 'select'}
<CursorArrowIcon /> >
</IconButton> <CursorArrowIcon />
</Container>
<Container>
<IconButton
name={ShapeType.Draw}
size="large"
onClick={selectDrawTool}
isActive={activeTool === ShapeType.Draw}
>
<Pencil1Icon />
</IconButton>
<IconButton
name={ShapeType.Rectangle}
size="large"
onClick={selectRectangleTool}
isActive={activeTool === ShapeType.Rectangle}
>
<SquareIcon />
</IconButton>
<IconButton
name={ShapeType.Circle}
size="large"
onClick={selectCircleTool}
isActive={activeTool === ShapeType.Circle}
>
<CircleIcon />
</IconButton>
<IconButton
name={ShapeType.Ellipse}
size="large"
onClick={selectEllipseTool}
isActive={activeTool === ShapeType.Ellipse}
>
<CircleIcon transform="rotate(-45) scale(1, .8)" />
</IconButton>
<IconButton
name={ShapeType.Line}
size="large"
onClick={selectLineTool}
isActive={activeTool === ShapeType.Line}
>
<DividerHorizontalIcon transform="rotate(-45)" />
</IconButton>
<IconButton
name={ShapeType.Ray}
size="large"
onClick={selectRayTool}
isActive={activeTool === ShapeType.Ray}
>
<SewingPinIcon transform="rotate(-135)" />
</IconButton>
<IconButton
name={ShapeType.Dot}
size="large"
onClick={selectDotTool}
isActive={activeTool === ShapeType.Dot}
>
<DotIcon />
</IconButton>
</Container>
<Container>
<IconButton size="medium" onClick={selectToolLock}>
{isToolLocked ? <LockClosedIcon /> : <LockOpen1Icon />}
</IconButton>
{isPenLocked && (
<IconButton size="medium" onClick={selectToolLock}>
<Pencil2Icon />
</IconButton> </IconButton>
)} </Container>
</Container> <Container>
<IconButton
name={ShapeType.Draw}
size={{ '@sm': 'small', '@md': 'large' }}
onClick={selectDrawTool}
isActive={activeTool === ShapeType.Draw}
>
<Pencil1Icon />
</IconButton>
<IconButton
name={ShapeType.Rectangle}
size={{ '@sm': 'small', '@md': 'large' }}
onClick={selectRectangleTool}
isActive={activeTool === ShapeType.Rectangle}
>
<SquareIcon />
</IconButton>
<IconButton
name={ShapeType.Circle}
size={{ '@sm': 'small', '@md': 'large' }}
onClick={selectCircleTool}
isActive={activeTool === ShapeType.Circle}
>
<CircleIcon />
</IconButton>
<IconButton
name={ShapeType.Ellipse}
size={{ '@sm': 'small', '@md': 'large' }}
onClick={selectEllipseTool}
isActive={activeTool === ShapeType.Ellipse}
>
<CircleIcon transform="rotate(-45) scale(1, .8)" />
</IconButton>
<IconButton
name={ShapeType.Line}
size={{ '@sm': 'small', '@md': 'large' }}
onClick={selectLineTool}
isActive={activeTool === ShapeType.Line}
>
<DividerHorizontalIcon transform="rotate(-45)" />
</IconButton>
<IconButton
name={ShapeType.Ray}
size={{ '@sm': 'small', '@md': 'large' }}
onClick={selectRayTool}
isActive={activeTool === ShapeType.Ray}
>
<SewingPinIcon transform="rotate(-135)" />
</IconButton>
<IconButton
name={ShapeType.Dot}
size={{ '@sm': 'small', '@md': 'large' }}
onClick={selectDotTool}
isActive={activeTool === ShapeType.Dot}
>
<DotIcon />
</IconButton>
</Container>
<Container>
<IconButton
size={{ '@sm': 'small', '@md': 'large' }}
onClick={selectToolLock}
>
{isToolLocked ? <LockClosedIcon /> : <LockOpen1Icon />}
</IconButton>
{isPenLocked && (
<IconButton
size={{ '@sm': 'small', '@md': 'large' }}
onClick={selectToolLock}
>
<Pencil2Icon />
</IconButton>
)}
</Container>
</Flex>
<UndoRedo /> <UndoRedo />
</OuterContainer> </OuterContainer>
) )
} }
const Spacer = styled('div', { flexGrow: 2 })
const OuterContainer = styled('div', { const OuterContainer = styled('div', {
position: 'relative', position: 'fixed',
gridArea: 'tools', bottom: 40,
left: 0,
right: 0,
padding: '0 8px 12px 8px', padding: '0 8px 12px 8px',
height: '100%',
width: '100%', width: '100%',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
flexWrap: 'wrap',
gap: 16, gap: 16,
zIndex: 200,
})
const Flex = styled('div', {
display: 'flex',
'& > *:nth-child(n+2)': {
marginLeft: 16,
},
}) })
const Container = styled('div', { const Container = styled('div', {
@ -157,8 +173,6 @@ const Container = styled('div', {
border: '1px solid $border', border: '1px solid $border',
pointerEvents: 'all', pointerEvents: 'all',
userSelect: 'none', userSelect: 'none',
zIndex: 200,
boxShadow: '0px 2px 25px rgba(0,0,0,.16)',
height: '100%', height: '100%',
display: 'flex', display: 'flex',
padding: 4, padding: 4,

View file

@ -9,7 +9,7 @@ const clear = () => state.send('CLEARED_PAGE')
export default function UndoRedo() { export default function UndoRedo() {
return ( return (
<Container> <Container size={{ '@sm': 'small' }}>
<IconButton onClick={undo}> <IconButton onClick={undo}>
<RotateCcw /> <RotateCcw />
</IconButton> </IconButton>
@ -25,7 +25,7 @@ export default function UndoRedo() {
const Container = styled('div', { const Container = styled('div', {
position: 'absolute', position: 'absolute',
bottom: 12, bottom: 64,
right: 12, right: 12,
backgroundColor: '$panel', backgroundColor: '$panel',
borderRadius: '4px', borderRadius: '4px',
@ -43,4 +43,12 @@ const Container = styled('div', {
height: 13, height: 13,
width: 13, width: 13,
}, },
variants: {
size: {
small: {
bottom: 12,
},
},
},
}) })

View file

@ -10,7 +10,7 @@ const zoomToActual = () => state.send('ZOOMED_TO_ACTUAL')
export default function Zoom() { export default function Zoom() {
return ( return (
<Container> <Container size={{ '@sm': 'small' }}>
<IconButton onClick={zoomOut}> <IconButton onClick={zoomOut}>
<ZoomOutIcon /> <ZoomOutIcon />
</IconButton> </IconButton>
@ -33,8 +33,8 @@ function ZoomCounter() {
const Container = styled('div', { const Container = styled('div', {
position: 'absolute', position: 'absolute',
bottom: 12,
left: 12, left: 12,
bottom: 64,
backgroundColor: '$panel', backgroundColor: '$panel',
borderRadius: '4px', borderRadius: '4px',
overflow: 'hidden', overflow: 'hidden',
@ -50,6 +50,14 @@ const Container = styled('div', {
'& svg': { '& svg': {
strokeWidth: 0, strokeWidth: 0,
}, },
variants: {
size: {
small: {
bottom: 12,
},
},
},
}) })
const ZoomButton = styled(IconButton, { const ZoomButton = styled(IconButton, {

View file

@ -1,18 +1,19 @@
import { useCallback, useRef } from "react" import { useCallback, useRef } from 'react'
import inputs from "state/inputs" import inputs from 'state/inputs'
import { Edge, Corner } from "types" import { Edge, Corner } from 'types'
import state from "../state" import state from '../state'
export default function useBoundsHandleEvents( export default function useBoundsHandleEvents(
handle: Edge | Corner | "rotate" handle: Edge | Corner | 'rotate'
) { ) {
const onPointerDown = useCallback( const onPointerDown = useCallback(
(e) => { (e) => {
if (e.buttons !== 1) return if (e.buttons !== 1) return
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation() e.stopPropagation()
e.currentTarget.setPointerCapture(e.pointerId) e.currentTarget.setPointerCapture(e.pointerId)
state.send("POINTED_BOUNDS_HANDLE", inputs.pointerDown(e, handle)) state.send('POINTED_BOUNDS_HANDLE', inputs.pointerDown(e, handle))
}, },
[handle] [handle]
) )
@ -20,18 +21,20 @@ export default function useBoundsHandleEvents(
const onPointerMove = useCallback( const onPointerMove = useCallback(
(e) => { (e) => {
if (e.buttons !== 1) return if (e.buttons !== 1) return
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation() e.stopPropagation()
state.send("MOVED_POINTER", inputs.pointerMove(e)) state.send('MOVED_POINTER', inputs.pointerMove(e))
}, },
[handle] [handle]
) )
const onPointerUp = useCallback((e) => { const onPointerUp = useCallback((e) => {
if (e.buttons !== 1) return if (e.buttons !== 1) return
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation() e.stopPropagation()
e.currentTarget.releasePointerCapture(e.pointerId) e.currentTarget.releasePointerCapture(e.pointerId)
e.currentTarget.replaceWith(e.currentTarget) e.currentTarget.replaceWith(e.currentTarget)
state.send("STOPPED_POINTING", inputs.pointerUp(e)) state.send('STOPPED_POINTING', inputs.pointerUp(e))
}, []) }, [])
return { onPointerDown, onPointerMove, onPointerUp } return { onPointerDown, onPointerMove, onPointerUp }

View file

@ -8,7 +8,8 @@ export default function useShapeEvents(
) { ) {
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
e.stopPropagation() if (!inputs.canAccept(e.pointerId)) return
// e.stopPropagation()
rGroup.current.setPointerCapture(e.pointerId) rGroup.current.setPointerCapture(e.pointerId)
state.send('POINTED_SHAPE', inputs.pointerDown(e, id)) state.send('POINTED_SHAPE', inputs.pointerDown(e, id))
}, },
@ -17,7 +18,8 @@ export default function useShapeEvents(
const handlePointerUp = useCallback( const handlePointerUp = useCallback(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
e.stopPropagation() if (!inputs.canAccept(e.pointerId)) return
// e.stopPropagation()
rGroup.current.releasePointerCapture(e.pointerId) rGroup.current.releasePointerCapture(e.pointerId)
state.send('STOPPED_POINTING', inputs.pointerUp(e)) state.send('STOPPED_POINTING', inputs.pointerUp(e))
}, },
@ -26,6 +28,7 @@ export default function useShapeEvents(
const handlePointerEnter = useCallback( const handlePointerEnter = useCallback(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
state.send('HOVERED_SHAPE', inputs.pointerEnter(e, id)) state.send('HOVERED_SHAPE', inputs.pointerEnter(e, id))
}, },
[id] [id]
@ -33,13 +36,17 @@ export default function useShapeEvents(
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
state.send('MOVED_OVER_SHAPE', inputs.pointerEnter(e, id)) state.send('MOVED_OVER_SHAPE', inputs.pointerEnter(e, id))
}, },
[id] [id]
) )
const handlePointerLeave = useCallback( const handlePointerLeave = useCallback(
() => state.send('UNHOVERED_SHAPE', { target: id }), (e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
state.send('UNHOVERED_SHAPE', { target: id })
},
[id] [id]
) )

View file

@ -2,7 +2,7 @@ import React, { useEffect, useRef } from 'react'
import state from 'state' import state from 'state'
import inputs from 'state/inputs' import inputs from 'state/inputs'
import * as vec from 'utils/vec' import * as vec from 'utils/vec'
import { usePinch } from 'react-use-gesture' import { useGesture } from 'react-use-gesture'
/** /**
* Capture zoom gestures (pinches, wheels and pans) and send to the state. * Capture zoom gestures (pinches, wheels and pans) and send to the state.
@ -12,91 +12,57 @@ import { usePinch } from 'react-use-gesture'
export default function useZoomEvents( export default function useZoomEvents(
ref: React.MutableRefObject<SVGSVGElement> ref: React.MutableRefObject<SVGSVGElement>
) { ) {
const rTouchDist = useRef(0)
useEffect(() => {
const element = ref.current
if (!element) return
function handleWheel(e: WheelEvent) {
e.preventDefault()
e.stopPropagation()
if (e.ctrlKey) {
state.send('ZOOMED_CAMERA', {
delta: e.deltaY,
...inputs.wheel(e),
})
return
}
state.send('PANNED_CAMERA', {
delta: [e.deltaX, e.deltaY],
...inputs.wheel(e),
})
}
function handleTouchMove(e: TouchEvent) {
e.preventDefault()
e.stopPropagation()
if (e.touches.length === 2) {
const { clientX: x0, clientY: y0 } = e.touches[0]
const { clientX: x1, clientY: y1 } = e.touches[1]
const dist = vec.dist([x0, y0], [x1, y1])
const point = vec.med([x0, y0], [x1, y1])
state.send('WHEELED', {
delta: dist - rTouchDist.current,
point,
})
rTouchDist.current = dist
}
}
element.addEventListener('wheel', handleWheel, { passive: false })
element.addEventListener('touchstart', handleTouchMove, { passive: false })
element.addEventListener('touchmove', handleTouchMove, { passive: false })
return () => {
element.removeEventListener('wheel', handleWheel)
element.removeEventListener('touchstart', handleTouchMove)
element.removeEventListener('touchmove', handleTouchMove)
}
}, [ref])
const rPinchDa = useRef<number[] | undefined>(undefined) const rPinchDa = useRef<number[] | undefined>(undefined)
const rPinchPoint = useRef<number[] | undefined>(undefined) const rPinchPoint = useRef<number[] | undefined>(undefined)
const bind = usePinch(({ pinching, da, origin }) => { const bind = useGesture(
if (!pinching) { {
state.send('STOPPED_PINCHING') onWheel: ({ event, delta }) => {
rPinchDa.current = undefined if (event.ctrlKey) {
rPinchPoint.current = undefined state.send('ZOOMED_CAMERA', {
return delta: delta[1],
...inputs.wheel(event as WheelEvent),
})
return
}
state.send('PANNED_CAMERA', {
delta,
...inputs.wheel(event as WheelEvent),
})
},
onPinch: ({ pinching, da, origin }) => {
if (!pinching) {
state.send('STOPPED_PINCHING')
rPinchDa.current = undefined
rPinchPoint.current = undefined
return
}
if (rPinchPoint.current === undefined) {
state.send('STARTED_PINCHING')
rPinchDa.current = da
rPinchPoint.current = origin
}
const [distanceDelta, angleDelta] = vec.sub(rPinchDa.current, da)
state.send('PINCHED', {
delta: vec.sub(rPinchPoint.current, origin),
point: origin,
distanceDelta,
angleDelta,
})
rPinchDa.current = da
rPinchPoint.current = origin
},
},
{
domTarget: document.body,
eventOptions: { passive: false },
} }
)
if (rPinchPoint.current === undefined) {
state.send('STARTED_PINCHING')
rPinchDa.current = da
rPinchPoint.current = origin
}
const [distanceDelta, angleDelta] = vec.sub(rPinchDa.current, da)
state.send('PINCHED', {
delta: vec.sub(rPinchPoint.current, origin),
point: origin,
distanceDelta,
angleDelta,
})
rPinchDa.current = da
rPinchPoint.current = origin
})
return { ...bind() } return { ...bind() }
} }

View file

@ -1,6 +1,6 @@
import { Data } from "types" import { Data } from 'types'
import { BaseCommand } from "./commands/command" import { BaseCommand } from './commands/command'
import state from "./state" import state from './state'
// A singleton to manage history changes. // A singleton to manage history changes.
@ -11,10 +11,11 @@ class BaseHistory<T> {
private _enabled = true private _enabled = true
execute = (data: T, command: BaseCommand<T>) => { execute = (data: T, command: BaseCommand<T>) => {
command.redo(data, true)
if (this.disabled) return if (this.disabled) return
this.stack = this.stack.slice(0, this.pointer + 1) this.stack = this.stack.slice(0, this.pointer + 1)
this.stack.push(command) this.stack.push(command)
command.redo(data, true)
this.pointer++ this.pointer++
if (this.stack.length > this.maxLength) { if (this.stack.length > this.maxLength) {
@ -26,26 +27,26 @@ class BaseHistory<T> {
} }
undo = (data: T) => { undo = (data: T) => {
if (this.disabled) return
if (this.pointer === -1) return if (this.pointer === -1) return
const command = this.stack[this.pointer] const command = this.stack[this.pointer]
command.undo(data) command.undo(data)
if (this.disabled) return
this.pointer-- this.pointer--
this.save(data) this.save(data)
} }
redo = (data: T) => { redo = (data: T) => {
if (this.disabled) return
if (this.pointer === this.stack.length - 1) return if (this.pointer === this.stack.length - 1) return
const command = this.stack[this.pointer + 1] const command = this.stack[this.pointer + 1]
command.redo(data, false) command.redo(data, false)
if (this.disabled) return
this.pointer++ this.pointer++
this.save(data) this.save(data)
} }
load(data: T, id = "code_slate_0.0.1") { load(data: T, id = 'code_slate_0.0.1') {
if (typeof window === "undefined") return if (typeof window === 'undefined') return
if (typeof localStorage === "undefined") return if (typeof localStorage === 'undefined') return
const savedData = localStorage.getItem(id) const savedData = localStorage.getItem(id)
@ -54,9 +55,9 @@ class BaseHistory<T> {
} }
} }
save = (data: T, id = "code_slate_0.0.1") => { save = (data: T, id = 'code_slate_0.0.1') => {
if (typeof window === "undefined") return if (typeof window === 'undefined') return
if (typeof localStorage === "undefined") return if (typeof localStorage === 'undefined') return
localStorage.setItem(id, JSON.stringify(this.prepareDataForSave(data))) localStorage.setItem(id, JSON.stringify(this.prepareDataForSave(data)))
} }
@ -110,14 +111,14 @@ class History extends BaseHistory<Data> {
restoredData.selectedIds = new Set(restoredData.selectedIds) restoredData.selectedIds = new Set(restoredData.selectedIds)
// Also restore camera position, which is saved separately in this app // Also restore camera position, which is saved separately in this app
const cameraInfo = localStorage.getItem("code_slate_camera") const cameraInfo = localStorage.getItem('code_slate_camera')
if (cameraInfo !== null) { if (cameraInfo !== null) {
Object.assign(restoredData.camera, JSON.parse(cameraInfo)) Object.assign(restoredData.camera, JSON.parse(cameraInfo))
// And update the CSS property // And update the CSS property
document.documentElement.style.setProperty( document.documentElement.style.setProperty(
"--camera-zoom", '--camera-zoom',
restoredData.camera.zoom.toString() restoredData.camera.zoom.toString()
) )
} }

View file

@ -1,7 +1,8 @@
import { PointerInfo } from "types" import { PointerInfo } from 'types'
import { isDarwin } from "utils/utils" import { isDarwin } from 'utils/utils'
class Inputs { class Inputs {
activePointerId?: number
points: Record<string, PointerInfo> = {} points: Record<string, PointerInfo> = {}
pointerDown(e: PointerEvent | React.PointerEvent, target: string) { pointerDown(e: PointerEvent | React.PointerEvent, target: string) {
@ -19,6 +20,7 @@ class Inputs {
} }
this.points[e.pointerId] = info this.points[e.pointerId] = info
this.activePointerId = e.pointerId
return info return info
} }
@ -78,6 +80,7 @@ class Inputs {
} }
delete this.points[e.pointerId] delete this.points[e.pointerId]
delete this.activePointerId
return info return info
} }
@ -87,6 +90,12 @@ class Inputs {
return { point: [e.clientX, e.clientY], shiftKey, ctrlKey, metaKey, altKey } return { point: [e.clientX, e.clientY], shiftKey, ctrlKey, metaKey, altKey }
} }
canAccept(pointerId: PointerEvent['pointerId']) {
return (
this.activePointerId === undefined || this.activePointerId === pointerId
)
}
get pointer() { get pointer() {
return this.points[Object.keys(this.points)[0]] return this.points[Object.keys(this.points)[0]]
} }

View file

@ -45,6 +45,11 @@ export default class RotateSession extends BaseSession {
for (let { id, center, offset, rotation } of initialShapes) { for (let { id, center, offset, rotation } of initialShapes) {
const shape = page.shapes[id] const shape = page.shapes[id]
// const rotationOffset = vec.sub(
// getBoundsCenter(getShapeBounds(shape)),
// getBoundsCenter(getRotatedBounds(shape))
// )
const nextRotation = isLocked const nextRotation = isLocked
? clampToRotationToSegments(rotation + rot, 24) ? clampToRotationToSegments(rotation + rot, 24)
: rotation + rot : rotation + rot
@ -100,11 +105,17 @@ export function getRotateSnapshot(data: Data) {
const center = getBoundsCenter(bounds) const center = getBoundsCenter(bounds)
const offset = vec.sub(center, shape.point) const offset = vec.sub(center, shape.point)
const rotationOffset = vec.sub(
center,
getBoundsCenter(getRotatedBounds(shape))
)
return { return {
id: shape.id, id: shape.id,
point: shape.point, point: shape.point,
rotation: shape.rotation, rotation: shape.rotation,
offset, offset,
rotationOffset,
center, center,
} }
}), }),

View file

@ -69,47 +69,7 @@ const initialData: Data = {
const state = createState({ const state = createState({
data: initialData, data: initialData,
on: { on: {
ZOOMED_CAMERA: { UNMOUNTED: [{ unless: 'isReadOnly', do: 'forceSave' }, { to: 'loading' }],
do: 'zoomCamera',
},
PANNED_CAMERA: {
do: 'panCamera',
},
ZOOMED_TO_ACTUAL: {
if: 'hasSelection',
do: 'zoomCameraToSelectionActual',
else: 'zoomCameraToActual',
},
ZOOMED_TO_SELECTION: {
if: 'hasSelection',
do: 'zoomCameraToSelection',
},
ZOOMED_TO_FIT: ['zoomCameraToFit', 'zoomCameraToActual'],
ZOOMED_IN: 'zoomIn',
ZOOMED_OUT: 'zoomOut',
RESET_CAMERA: 'resetCamera',
TOGGLED_SHAPE_LOCK: { if: 'hasSelection', do: 'lockSelection' },
TOGGLED_SHAPE_HIDE: { if: 'hasSelection', do: 'hideSelection' },
TOGGLED_SHAPE_ASPECT_LOCK: {
if: 'hasSelection',
do: 'aspectLockSelection',
},
SELECTED_SELECT_TOOL: { to: 'selecting' },
SELECTED_DRAW_TOOL: { unless: 'isReadOnly', to: 'draw' },
SELECTED_DOT_TOOL: { unless: 'isReadOnly', to: 'dot' },
SELECTED_CIRCLE_TOOL: { unless: 'isReadOnly', to: 'circle' },
SELECTED_ELLIPSE_TOOL: { unless: 'isReadOnly', to: 'ellipse' },
SELECTED_RAY_TOOL: { unless: 'isReadOnly', to: 'ray' },
SELECTED_LINE_TOOL: { unless: 'isReadOnly', to: 'line' },
SELECTED_POLYLINE_TOOL: { unless: 'isReadOnly', to: 'polyline' },
SELECTED_RECTANGLE_TOOL: { unless: 'isReadOnly', to: 'rectangle' },
TOGGLED_CODE_PANEL_OPEN: 'toggleCodePanel',
TOGGLED_STYLE_PANEL_OPEN: 'toggleStylePanel',
CHANGED_STYLE: ['updateStyles', 'applyStylesToSelection'],
SELECTED_ALL: { to: 'selecting', do: 'selectAll' },
NUDGED: { do: 'nudgeSelection' },
USED_PEN_DEVICE: 'enablePenLock',
DISABLED_PEN_LOCK: 'disablePenLock',
}, },
initial: 'loading', initial: 'loading',
states: { states: {
@ -131,10 +91,48 @@ const state = createState({
else: ['zoomCameraToFit', 'zoomCameraToActual'], else: ['zoomCameraToFit', 'zoomCameraToActual'],
}, },
on: { on: {
UNMOUNTED: [ ZOOMED_CAMERA: {
{ unless: 'isReadOnly', do: 'forceSave' }, do: 'zoomCamera',
{ to: 'loading' }, },
], PANNED_CAMERA: {
do: 'panCamera',
},
ZOOMED_TO_ACTUAL: {
if: 'hasSelection',
do: 'zoomCameraToSelectionActual',
else: 'zoomCameraToActual',
},
ZOOMED_TO_SELECTION: {
if: 'hasSelection',
do: 'zoomCameraToSelection',
},
ZOOMED_TO_FIT: ['zoomCameraToFit', 'zoomCameraToActual'],
ZOOMED_IN: 'zoomIn',
ZOOMED_OUT: 'zoomOut',
RESET_CAMERA: 'resetCamera',
TOGGLED_SHAPE_LOCK: { if: 'hasSelection', do: 'lockSelection' },
TOGGLED_SHAPE_HIDE: { if: 'hasSelection', do: 'hideSelection' },
TOGGLED_SHAPE_ASPECT_LOCK: {
if: 'hasSelection',
do: 'aspectLockSelection',
},
SELECTED_SELECT_TOOL: { to: 'selecting' },
SELECTED_DRAW_TOOL: { unless: 'isReadOnly', to: 'draw' },
SELECTED_DOT_TOOL: { unless: 'isReadOnly', to: 'dot' },
SELECTED_CIRCLE_TOOL: { unless: 'isReadOnly', to: 'circle' },
SELECTED_ELLIPSE_TOOL: { unless: 'isReadOnly', to: 'ellipse' },
SELECTED_RAY_TOOL: { unless: 'isReadOnly', to: 'ray' },
SELECTED_LINE_TOOL: { unless: 'isReadOnly', to: 'line' },
SELECTED_POLYLINE_TOOL: { unless: 'isReadOnly', to: 'polyline' },
SELECTED_RECTANGLE_TOOL: { unless: 'isReadOnly', to: 'rectangle' },
TOGGLED_CODE_PANEL_OPEN: 'toggleCodePanel',
TOGGLED_STYLE_PANEL_OPEN: 'toggleStylePanel',
CHANGED_STYLE: ['updateStyles', 'applyStylesToSelection'],
SELECTED_ALL: { to: 'selecting', do: 'selectAll' },
NUDGED: { do: 'nudgeSelection' },
USED_PEN_DEVICE: 'enablePenLock',
DISABLED_PEN_LOCK: 'disablePenLock',
CLEARED_PAGE: ['selectAll', 'deleteSelection'],
}, },
initial: 'selecting', initial: 'selecting',
states: { states: {
@ -143,10 +141,8 @@ const state = createState({
SAVED: 'forceSave', SAVED: 'forceSave',
UNDO: 'undo', UNDO: 'undo',
REDO: 'redo', REDO: 'redo',
CLEARED_PAGE: ['selectAll', 'deleteSelection'],
SAVED_CODE: 'saveCode', SAVED_CODE: 'saveCode',
DELETED: 'deleteSelection', DELETED: 'deleteSelection',
STARTED_PINCHING: { to: 'pinching' },
INCREASED_CODE_FONT_SIZE: 'increaseCodeFontSize', INCREASED_CODE_FONT_SIZE: 'increaseCodeFontSize',
DECREASED_CODE_FONT_SIZE: 'decreaseCodeFontSize', DECREASED_CODE_FONT_SIZE: 'decreaseCodeFontSize',
CHANGED_CODE_CONTROL: 'updateControls', CHANGED_CODE_CONTROL: 'updateControls',
@ -164,6 +160,7 @@ const state = createState({
notPointing: { notPointing: {
on: { on: {
CANCELLED: 'clearSelectedIds', CANCELLED: 'clearSelectedIds',
STARTED_PINCHING: { to: 'pinching' },
POINTED_CANVAS: { to: 'brushSelecting' }, POINTED_CANVAS: { to: 'brushSelecting' },
POINTED_BOUNDS: { to: 'pointingBounds' }, POINTED_BOUNDS: { to: 'pointingBounds' },
POINTED_BOUNDS_HANDLE: { POINTED_BOUNDS_HANDLE: {
@ -269,7 +266,7 @@ const state = createState({
'startBrushSession', 'startBrushSession',
], ],
on: { on: {
STARTED_PINCHING: { to: 'pinching' }, STARTED_PINCHING: { do: 'completeSession', to: 'pinching' },
MOVED_POINTER: 'updateBrushSession', MOVED_POINTER: 'updateBrushSession',
PANNED_CAMERA: 'updateBrushSession', PANNED_CAMERA: 'updateBrushSession',
STOPPED_POINTING: { do: 'completeSession', to: 'selecting' }, STOPPED_POINTING: { do: 'completeSession', to: 'selecting' },
@ -280,14 +277,30 @@ const state = createState({
}, },
pinching: { pinching: {
on: { on: {
STOPPED_PINCHING: { to: 'selecting' },
PINCHED: { do: 'pinchCamera' }, PINCHED: { do: 'pinchCamera' },
}, },
initial: 'selectPinching',
states: {
selectPinching: {
on: {
STOPPED_PINCHING: { to: 'selecting' },
},
},
toolPinching: {
on: {
STOPPED_PINCHING: { to: 'usingTool.previous' },
},
},
},
}, },
usingTool: { usingTool: {
initial: 'draw', initial: 'draw',
onEnter: 'clearSelectedIds', onEnter: 'clearSelectedIds',
on: { on: {
STARTED_PINCHING: {
do: 'breakSession',
to: 'pinching.toolPinching',
},
TOGGLED_TOOL_LOCK: 'toggleToolLock', TOGGLED_TOOL_LOCK: 'toggleToolLock',
}, },
states: { states: {
@ -319,7 +332,7 @@ const state = createState({
to: 'draw.creating', to: 'draw.creating',
}, },
CANCELLED: { CANCELLED: {
do: ['cancelSession', 'deleteSelection'], do: 'breakSession',
to: 'selecting', to: 'selecting',
}, },
MOVED_POINTER: 'updateDrawSession', MOVED_POINTER: 'updateDrawSession',
@ -359,7 +372,7 @@ const state = createState({
}, },
], ],
CANCELLED: { CANCELLED: {
do: ['cancelSession', 'deleteSelection'], do: 'breakSession',
to: 'selecting', to: 'selecting',
}, },
}, },
@ -545,7 +558,7 @@ const state = createState({
}, },
], ],
CANCELLED: { CANCELLED: {
do: ['cancelSession', 'deleteSelection'], do: 'breakSession',
to: 'selecting', to: 'selecting',
}, },
}, },
@ -662,6 +675,13 @@ const state = createState({
/* -------------------- Sessions -------------------- */ /* -------------------- Sessions -------------------- */
// Shared // Shared
breakSession(data) {
session?.cancel(data)
session = undefined
history.disable()
commands.deleteSelected(data)
history.enable()
},
cancelSession(data) { cancelSession(data) {
session?.cancel(data) session?.cancel(data)
session = undefined session = undefined

View file

@ -42,6 +42,10 @@ const { styled, global, css, theme, getCssString } = createCss({
zIndices: {}, zIndices: {},
transitions: {}, transitions: {},
}, },
media: {
sm: '(min-width: 640px)',
md: '(min-width: 768px)',
},
utils: { utils: {
zDash: () => (value: number) => { zDash: () => (value: number) => {
return { return {