Adds dragging / translation

This commit is contained in:
Steve Ruiz 2021-05-13 07:44:52 +01:00
parent 7ec9457ac2
commit 8c81823b20
18 changed files with 340 additions and 141 deletions

View file

@ -1,7 +1,10 @@
import { useRef } from "react"
import state, { useSelector } from "state"
import inputs from "state/inputs"
import styled from "styles"
export default function BoundsBg() {
const rBounds = useRef<SVGRectElement>(null)
const bounds = useSelector((state) => state.values.selectedBounds)
if (!bounds) return null
@ -10,19 +13,15 @@ export default function BoundsBg() {
return (
<StyledBoundsBg
ref={rBounds}
x={minX}
y={minY}
width={width}
height={height}
onPointerDown={(e) => {
if (e.buttons !== 1) return
state.send("POINTED_BOUNDS", {
shiftKey: e.shiftKey,
optionKey: e.altKey,
metaKey: e.metaKey || e.ctrlKey,
ctrlKey: e.ctrlKey,
buttons: e.buttons,
})
rBounds.current.setPointerCapture(e.pointerId)
state.send("POINTED_BOUNDS", inputs.pointerDown(e))
}}
/>
)

View file

@ -1,6 +1,7 @@
import state, { useSelector } from "state"
import { motion } from "framer-motion"
import styled from "styles"
import inputs from "state/inputs"
export default function Bounds() {
const bounds = useSelector((state) => state.values.selectedBounds)
@ -23,6 +24,8 @@ export default function Bounds() {
height={height}
pointerEvents="none"
/>
{width * zoom > 8 && (
<>
<Corner
x={minX}
y={minY}
@ -55,6 +58,8 @@ export default function Bounds() {
height={cp}
cursor="nesw-resize"
/>
</>
)}
<EdgeHorizontal
x={minX + p}
y={minY}
@ -65,11 +70,7 @@ export default function Bounds() {
if (e.buttons !== 1) return
state.send("POINTED_BOUNDS_EDGE", {
edge: 0,
shiftKey: e.shiftKey,
optionKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
buttons: e.buttons,
...inputs.pointerDown(e),
})
document.body.style.cursor = "ns-resize"
}}
@ -84,11 +85,7 @@ export default function Bounds() {
if (e.buttons !== 1) return
state.send("POINTED_BOUNDS_EDGE", {
edge: 1,
shiftKey: e.shiftKey,
optionKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
buttons: e.buttons,
...inputs.pointerDown(e),
})
document.body.style.cursor = "ew-resize"
}}
@ -103,11 +100,7 @@ export default function Bounds() {
if (e.buttons !== 1) return
state.send("POINTED_BOUNDS_EDGE", {
edge: 2,
shiftKey: e.shiftKey,
optionKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
buttons: e.buttons,
...inputs.pointerDown(e),
})
document.body.style.cursor = "ns-resize"
}}
@ -122,11 +115,7 @@ export default function Bounds() {
if (e.buttons !== 1) return
state.send("POINTED_BOUNDS_EDGE", {
edge: 3,
shiftKey: e.shiftKey,
optionKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
buttons: e.buttons,
...inputs.pointerDown(e),
})
document.body.style.cursor = "ew-resize"
}}
@ -168,11 +157,7 @@ function Corner({
if (e.buttons !== 1) return
state.send("POINTED_ROTATE_CORNER", {
corner,
shiftKey: e.shiftKey,
optionKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
buttons: e.buttons,
...inputs.pointerDown(e),
})
document.body.style.cursor = "grabbing"
}}
@ -190,18 +175,13 @@ function Corner({
if (e.buttons !== 1) return
state.send("POINTED_BOUNDS_CORNER", {
corner,
shiftKey: e.shiftKey,
optionKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
buttons: e.buttons,
...inputs.pointerDown(e),
})
document.body.style.cursor = "nesw-resize"
}}
onPanEnd={restoreCursor}
onTap={restoreCursor}
style={{ cursor }}
className="strokewidth-ui stroke-bounds fill-corner"
/>
</g>
)
@ -268,9 +248,9 @@ function EdgeVertical({
)
}
function restoreCursor() {
function restoreCursor(e: PointerEvent) {
state.send("STOPPED_POINTING", { id: "bounds", ...inputs.pointerUp(e) })
document.body.style.cursor = "default"
state.send("STOPPED_POINTING")
}
const StyledEdge = styled(motion.rect, {

View file

@ -8,6 +8,7 @@ import Brush from "./brush"
import state from "state"
import Bounds from "./bounds"
import BoundsBg from "./bounds-bg"
import inputs from "state/inputs"
export default function Canvas() {
const rCanvas = useRef<SVGSVGElement>(null)
@ -18,16 +19,16 @@ export default function Canvas() {
const handlePointerDown = useCallback((e: React.PointerEvent) => {
rCanvas.current.setPointerCapture(e.pointerId)
state.send("POINTED_CANVAS", getPointerEventInfo(e))
state.send("POINTED_CANVAS", inputs.pointerDown(e))
}, [])
const handlePointerMove = useCallback((e: React.PointerEvent) => {
state.send("MOVED_POINTER", getPointerEventInfo(e))
state.send("MOVED_POINTER", inputs.pointerMove(e))
}, [])
const handlePointerUp = useCallback((e: React.PointerEvent) => {
rCanvas.current.releasePointerCapture(e.pointerId)
state.send("STOPPED_POINTING", getPointerEventInfo(e))
state.send("STOPPED_POINTING", { id: "canvas", ...inputs.pointerUp(e) })
}, [])
return (

View file

@ -1,6 +1,7 @@
import React, { useCallback, useRef, memo } from "react"
import state, { useSelector } from "state"
import { getPointerEventInfo } from "utils/utils"
import inputs from "state/inputs"
import shapes from "lib/shapes"
import styled from "styles"
@ -18,7 +19,7 @@ function Shape({ id }: { id: string }) {
(e: React.PointerEvent) => {
e.stopPropagation()
rGroup.current.setPointerCapture(e.pointerId)
state.send("POINTED_SHAPE", { id, ...getPointerEventInfo(e) })
state.send("POINTED_SHAPE", { id, ...inputs.pointerDown(e) })
},
[id]
)
@ -27,20 +28,18 @@ function Shape({ id }: { id: string }) {
(e: React.PointerEvent) => {
e.stopPropagation()
rGroup.current.releasePointerCapture(e.pointerId)
state.send("STOPPED_POINTING_SHAPE", { id, ...getPointerEventInfo(e) })
state.send("STOPPED_POINTING", { id, ...inputs.pointerUp(e) })
},
[id]
)
const handlePointerEnter = useCallback(
(e: React.PointerEvent) =>
state.send("HOVERED_SHAPE", { id, ...getPointerEventInfo(e) }),
(e: React.PointerEvent) => state.send("HOVERED_SHAPE", { id }),
[id]
)
const handlePointerLeave = useCallback(
(e: React.PointerEvent) =>
state.send("UNHOVERED_SHAPE", { id, ...getPointerEventInfo(e) }),
(e: React.PointerEvent) => state.send("UNHOVERED_SHAPE", { id }),
[id]
)

View file

@ -56,7 +56,8 @@ const Circle: BaseLibShape<ShapeType.Circle> = {
return shape
},
translate(shape) {
translate(shape, delta) {
shape.point = vec.add(shape.point, delta)
return shape
},

View file

@ -52,7 +52,8 @@ const Dot: BaseLibShape<ShapeType.Dot> = {
return shape
},
translate(shape) {
translate(shape, delta) {
shape.point = vec.add(shape.point, delta)
return shape
},

View file

@ -61,7 +61,8 @@ const Polyline: BaseLibShape<ShapeType.Polyline> = {
return shape
},
translate(shape) {
translate(shape, delta) {
shape.point = vec.add(shape.point, delta)
return shape
},

View file

@ -54,7 +54,8 @@ const Rectangle: BaseLibShape<ShapeType.Rectangle> = {
return shape
},
translate(shape) {
translate(shape, delta) {
shape.point = vec.add(shape.point, delta)
return shape
},

View file

@ -4,30 +4,14 @@ import { Data } from "types"
export type CommandFn<T> = (data: T, initial?: boolean) => void
export enum CommandType {
ChangeBounds,
CreateGlob,
CreateNode,
Delete,
Split,
Move,
MoveAnchor,
ReorderGlobs,
ReorderNodes,
Paste,
ToggleCap,
ToggleLocked,
SetProperty,
SetItems,
Transform,
}
/**
* A command makes changes to some applicate state. Every command has an "undo"
* method to reverse its changes. The apps history is a series of commands.
*/
export class BaseCommand<T extends any> {
timestamp = Date.now()
name: string
category: string
private undoFn: CommandFn<T>
private doFn: CommandFn<T>
protected restoreBeforeSelectionState: (data: T) => void
@ -36,11 +20,14 @@ export class BaseCommand<T extends any> {
protected manualSelection: boolean
constructor(options: {
type: CommandType
do: CommandFn<T>
undo: CommandFn<T>
name: string
category: string
manualSelection?: boolean
}) {
this.name = options.name
this.category = options.category
this.doFn = options.do
this.undoFn = options.undo
this.manualSelection = options.manualSelection || false
@ -87,8 +74,11 @@ export class BaseCommand<T extends any> {
* to mutate the state's data. Actions do not effect the "active states" in
* the app.
*/
export class Command extends BaseCommand<Data> {
export default class Command extends BaseCommand<Data> {
saveSelectionState = (data: Data) => {
return (data: Data) => {}
const selectedIds = new Set(data.selectedIds)
return (data: Data) => {
data.selectedIds = selectedIds
}
}
}

View file

@ -1,7 +1,9 @@
import { Data } from "types"
import { BaseCommand } from "./command"
class BaseHistory<T> {
// A singleton to manage history changes.
class History<T> {
private stack: BaseCommand<T>[] = []
private pointer = -1
private maxLength = 100
@ -44,7 +46,7 @@ class BaseHistory<T> {
if (typeof window === "undefined") return
if (typeof localStorage === "undefined") return
localStorage.setItem("glob_aldata_v6", JSON.stringify(data))
localStorage.setItem("code_slate_0.0.1", JSON.stringify(data))
}
disable = () => {
@ -60,4 +62,4 @@ class BaseHistory<T> {
}
}
export default new BaseHistory<Data>()
export default new History<Data>()

5
state/commands/index.ts Normal file
View file

@ -0,0 +1,5 @@
import translate from "./translate-command"
const commands = { translate }
export default commands

View file

@ -0,0 +1,32 @@
import Command from "./command"
import history from "./history"
import { TranslateSnapshot } from "state/sessions/translate-session"
import { Data } from "types"
export default function translateCommand(
data: Data,
before: TranslateSnapshot,
after: TranslateSnapshot
) {
history.execute(
data,
new Command({
name: "translate_shapes",
category: "canvas",
do(data) {
const { shapes } = data.document.pages[after.currentPageId]
for (let { id, point } of after.shapes) {
shapes[id].point = point
}
},
undo(data) {
const { shapes } = data.document.pages[before.currentPageId]
for (let { id, point } of before.shapes) {
shapes[id].point = point
}
},
})
)
}

52
state/inputs.tsx Normal file
View file

@ -0,0 +1,52 @@
import { PointerInfo } from "types"
class Inputs {
points: Record<string, PointerInfo> = {}
pointerDown(e: PointerEvent | React.PointerEvent) {
const { shiftKey, ctrlKey, metaKey, altKey } = e
this.points[e.pointerId] = {
pointerId: e.pointerId,
origin: [e.clientX, e.clientY],
point: [e.clientX, e.clientY],
shiftKey,
ctrlKey,
metaKey,
altKey,
}
return this.points[e.pointerId]
}
pointerMove(e: PointerEvent | React.PointerEvent) {
if (this.points[e.pointerId]) {
this.points[e.pointerId].point = [e.clientX, e.clientY]
return this.points[e.pointerId]
}
const { shiftKey, ctrlKey, metaKey, altKey } = e
return {
pointerId: e.pointerId,
origin: [e.clientX, e.clientY],
point: [e.clientX, e.clientY],
shiftKey,
ctrlKey,
metaKey,
altKey,
}
}
pointerUp(e: PointerEvent | React.PointerEvent) {
this.points[e.pointerId].point = [e.clientX, e.clientY]
const info = this.points[e.pointerId]
delete this.points[e.pointerId]
return info
}
}
export default new Inputs()

View file

@ -3,15 +3,15 @@ import { Data } from "types"
export default class BaseSession {
constructor(data: Data) {}
update = (data: Data, ...args: unknown[]) => {
update(data: Data, ...args: unknown[]) {
// Update the state
}
complete = (data: Data, ...args: unknown[]) => {
complete(data: Data, ...args: unknown[]) {
// Create a command
}
cancel = (data: Data) => {
cancel(data: Data) {
// Clean up the change
}
}

View file

@ -1,4 +1,5 @@
import BaseSession from "./brush-session"
import BaseSession from "./base-session"
import BrushSession from "./brush-session"
import TranslateSession from "./translate-session"
export { BrushSession, BaseSession }
export { BrushSession, BaseSession, TranslateSession }

View file

@ -0,0 +1,62 @@
import { Data } from "types"
import * as vec from "utils/vec"
import BaseSession from "./base-session"
import commands from "state/commands"
import { current } from "immer"
export default class TranslateSession extends BaseSession {
delta = [0, 0]
origin: number[]
snapshot: TranslateSnapshot
constructor(data: Data, point: number[]) {
super(data)
this.origin = point
this.snapshot = getTranslateSnapshot(data)
}
update(data: Data, point: number[]) {
const { currentPageId, shapes } = this.snapshot
const { document } = data
const delta = vec.vec(this.origin, point)
for (let shape of shapes) {
document.pages[currentPageId].shapes[shape.id].point = vec.add(
shape.point,
delta
)
}
}
cancel(data: Data) {
const { document } = data
for (let shape of this.snapshot.shapes) {
document.pages[this.snapshot.currentPageId].shapes[shape.id].point =
shape.point
}
}
complete(data: Data) {
commands.translate(data, this.snapshot, getTranslateSnapshot(data))
}
}
export function getTranslateSnapshot(data: Data) {
const {
document: { pages },
currentPageId,
} = current(data)
const { shapes } = pages[currentPageId]
return {
currentPageId,
shapes: Array.from(data.selectedIds.values())
.map((id) => shapes[id])
.map(({ id, point }) => ({ id, point })),
}
}
export type TranslateSnapshot = ReturnType<typeof getTranslateSnapshot>

View file

@ -1,12 +1,13 @@
import { createSelectorHook, createState } from "@state-designer/react"
import { clamp, getCommonBounds, screenToWorld } from "utils/utils"
import * as vec from "utils/vec"
import { Bounds, Data, Shape, ShapeType } from "types"
import { Bounds, Data, PointerInfo, Shape, ShapeType } from "types"
import { defaultDocument } from "./data"
import Shapes from "lib/shapes"
import * as Sessions from "./sessions"
const initialData: Data = {
isReadOnly: false,
camera: {
point: [0, 0],
zoom: 1,
@ -31,8 +32,12 @@ const state = createState({
initial: "selecting",
states: {
selecting: {
initial: "notPointing",
states: {
notPointing: {
on: {
POINTED_CANVAS: { to: "brushSelecting" },
POINTED_BOUNDS: { to: "pointingBounds" },
POINTED_SHAPE: [
"setPointedId",
{
@ -40,12 +45,48 @@ const state = createState({
then: {
if: "isPointedShapeSelected",
do: "pullPointedIdFromSelectedIds",
else: "pushPointedIdToSelectedIds",
else: {
do: "pushPointedIdToSelectedIds",
to: "pointingBounds",
},
else: ["clearSelectedIds", "pushPointedIdToSelectedIds"],
},
else: [
{
unless: "isPointedShapeSelected",
do: ["clearSelectedIds", "pushPointedIdToSelectedIds"],
},
{
to: "pointingBounds",
},
],
},
],
},
},
pointingBounds: {
on: {
STOPPED_POINTING: [
{
unless: "isPressingShiftKey",
do: ["clearSelectedIds", "pushPointedIdToSelectedIds"],
},
{ to: "notPointing" },
],
MOVED_POINTER: {
unless: "isReadOnly",
if: "distanceImpliesDrag",
to: "draggingSelection",
},
},
},
draggingSelection: {
onEnter: "startTranslateSession",
on: {
MOVED_POINTER: "updateTranslateSession",
PANNED_CAMERA: "updateTranslateSession",
STOPPED_POINTING: { do: "completeSession", to: "selecting" },
CANCELLED: { do: "cancelSession", to: "selecting" },
},
},
brushSelecting: {
onEnter: [
@ -60,7 +101,15 @@ const state = createState({
},
},
},
},
},
conditions: {
isReadOnly(data) {
return data.isReadOnly
},
distanceImpliesDrag(data, payload: PointerInfo) {
return vec.dist2(payload.origin, payload.point) > 16
},
isPointedShapeSelected(data) {
return data.selectedIds.has(data.pointedId)
},
@ -77,6 +126,7 @@ const state = createState({
session.complete(data)
session = undefined
},
// Brushing
startBrushSession(data, payload: { point: number[] }) {
session = new Sessions.BrushSession(
data,
@ -86,6 +136,17 @@ const state = createState({
updateBrushSession(data, payload: { point: number[] }) {
session.update(data, screenToWorld(payload.point, data))
},
// Dragging / Translating
startTranslateSession(data, payload: { point: number[] }) {
session = new Sessions.TranslateSession(
data,
screenToWorld(payload.point, data)
)
},
updateTranslateSession(data, payload: { point: number[] }) {
session.update(data, screenToWorld(payload.point, data))
},
// Selection
setPointedId(data, payload: { id: string }) {
data.pointedId = payload.id

View file

@ -1,6 +1,7 @@
import React from "react"
export interface Data {
isReadOnly: boolean
camera: {
point: number[]
zoom: number
@ -124,8 +125,18 @@ export type BaseLibShape<K extends ShapeType> = {
getBounds(shape: Shapes[K]): Bounds
hitTest(shape: Shapes[K], test: number[]): boolean
rotate(shape: Shapes[K]): Shapes[K]
translate(shape: Shapes[K]): Shapes[K]
translate(shape: Shapes[K], delta: number[]): Shapes[K]
scale(shape: Shapes[K], scale: number): Shapes[K]
stretch(shape: Shapes[K], scaleX: number, scaleY: number): Shapes[K]
render(shape: Shapes[K]): JSX.Element
}
export interface PointerInfo {
pointerId: number
origin: number[]
point: number[]
shiftKey: boolean
ctrlKey: boolean
metaKey: boolean
altKey: boolean
}