import paper from 'paper';
import { createMachine, assign, type AnyEventObject } from 'xstate';
import { IDLE, PAN, MODIFIERS, MOUSE } from './constants.ts';
type Event = paper.ToolEvent
type Context = {
maxZoom: number,
zoom: number,
x: number,
y: number,
grid: {
visible: Boolean,
size: number,
stroke: number,
color: number[],
opacity: number
},
zoomFactor: number,
};
type State = {
event: {
event: Event
},
context: Context
};
/**
* methods that update the state-machine instance's internal context
*/
const assigns = {
cursorPosition: assign({
x: ({ event }) => event.event.point.x,
y: ({ event }) => event.event.point.y,
}),
canvasZoom: assign({
zoom: ({ event, context }) => {
const { zoomFactor, zoom, maxZoom } = context;
const { wheelData, wheelDelta } = event.event;
const zoomIn = wheelData && wheelData > 0 || wheelDelta && wheelDelta > 0;
const newZoom = zoomIn ?
Math.min(zoom * zoomFactor, maxZoom) :
Math.max(zoom * 1 / zoomFactor, 1/maxZoom);
return newZoom;
},
}),
sliderZoom: assign({
zoom: ({ event, context }) => {
const { maxZoom } = context;
const lowerBound = 1/maxZoom;
const percent = Number(event.value)/100;
return (maxZoom - lowerBound) * percent + lowerBound;
}
})
};
/**
* pure functions as fire & forget side effects of the state machine
*/
const action = {
panView: ({ event }: State) => {
const { point, downPoint } = event.event;
const panOffset = point.subtract(downPoint);
paper.view.center = paper.view.center.subtract(panOffset);
},
canvasZoom: ({ context }: State) => paper.view.zoom = context.zoom,
sliderZoom: ({ context }: State) => paper.view.zoom = context.zoom
};
/**
* internal conditions for guarding transitions
*/
const guards = {
isClick: (event: any, conditions: Record<string, Boolean | number>) => {
console.log("event: ", event);
console.log("conditions: ", conditions);
const booleans = [];
for (const key in conditions) {
booleans.push(event.event[key] === conditions[key]);
}
return booleans.every(Boolean);
},
isLeftClick: ({ event }: State) => {
const ans = guards.isClick(
event.event,
{ ...MODIFIERS, button: MOUSE.left }
)
return ans
},
isTouchStart: ({ event }: State) => {
const { event: _event } = event.event as unknown as { event: { type: string }};
const isTouchStart = _event.type === "touchstart";
if (isTouchStart) {
event.event.preventDefault();
return true;
}
return false;
}
}
export const canvasMachine = createMachine({
id: "canvas-machine",
initial: "IDLE",
context: {
maxZoom: 4.0,
zoom: 1,
x: 0,
y: 0,
grid: {
visible: true,
size: 70,
stroke: 2,
color: [0, 1, 1],
opacity: 0.2
},
zoomFactor: 1.05,
},
states: {
IDLE: {
on: {
ASSIGN_ZOOM: [
{
actions: [
action.sliderZoom as unknown as AnyEventObject,
assigns.sliderZoom as unknown as AnyEventObject
]
}
],
MOUSE_MOVE: [
{
target: IDLE,
actions: [
assigns.cursorPosition as unknown as AnyEventObject
]
},
],
MOUSE_DOWN: [
{
target: PAN,
guard: guards.isLeftClick as unknown as AnyEventObject
},
{
target: PAN,
guard: guards.isTouchStart as unknown as AnyEventObject
},
],
MOUSE_WHEEL: [
{
target: IDLE,
actions: [
action.canvasZoom as unknown as AnyEventObject,
assigns.canvasZoom as unknown as AnyEventObject
]
}
],
},
},
PAN: {
on: {
MOUSE_UP: { target: IDLE },
MOUSE_DRAG: {
actions: [action.panView as unknown as AnyEventObject]
}
}
}
}
});
import React, { useEffect } from "react";
import { type SnapshotFrom } from 'xstate';
import { useMachine, useSelector } from '@xstate/react';
import { canvasMachine } from './canvasMachine';
import { connectPaper } from './connectPaper.ts';
type CanvasInstance = SnapshotFrom<typeof canvasMachine>;
const CANVAS_ID = 'demo-canvas';
const getZoomSlider = (state: CanvasInstance) => {
const { zoom, maxZoom } = state.context;
return Math.floor(100 * zoom / (maxZoom-1/maxZoom)) - 6;
}
export const App = () => {
const [_, send, actorRef]= useMachine(canvasMachine);
const zoomSlider = useSelector(actorRef, getZoomSlider);
const handleSlider = (event: React.ChangeEvent<HTMLInputElement>) => send(
{ type: "ASSIGN_ZOOM", value: event.target.value }
);
useEffect(() => {
connectPaper(CANVAS_ID, actorRef);
}, []);
return (
<div className="touch-none grid grid-cols-auto w-full h-full">
<div className="xl:col-span-1 lg:col-span-2 md:col-span-2 sm:col-span-2 xs:col-span-2 flex flex-col w-full h-full items-center justify-top">
<div className="flex flex-col items-center pb-4 pt-4">
<label htmlFor="zoom-slider">zoom: {zoomSlider}</label>
<input
id="zoom-slider"
type="range"
value={zoomSlider}
onChange={handleSlider}
className="
appearance-none bg-transparent
[&::-webkit-slider-runnable-track]:rounded-full
[&::-webkit-slider-runnable-track]:bg-black/25
"
/>
</div>
</div>
<div className="touch-none xl:col-span-1 h-5/6 lg:col-span-2 md:col-span-2 sm:col-span-2 xs:col-span-2 flex flex-col items-center w-full">
<canvas id={CANVAS_ID} height={500} className={'border-slate-200 w-full h-full border rounded'}/>
</div>
</div>
);
}