adds commands, brush, history

This commit is contained in:
Steve Ruiz 2021-05-10 13:16:57 +01:00
parent f38481efee
commit a5659380c4
14 changed files with 412 additions and 47 deletions

View file

@ -0,0 +1 @@
export default function BoundsBg() {}

View file

@ -0,0 +1 @@
export default function Bounds() {}

View file

@ -0,0 +1,23 @@
import { useSelector } from "state"
import styled from "styles"
export default function Brush() {
const brush = useSelector(({ data }) => data.brush)
if (!brush) return null
return (
<BrushRect
x={brush.minX}
y={brush.minY}
width={brush.width}
height={brush.height}
className="brush"
/>
)
}
const BrushRect = styled("rect", {
fill: "$brushFill",
stroke: "$brushStroke",
})

View file

@ -1,8 +1,11 @@
import styled from "styles"
import { useRef } from "react"
import { getPointerEventInfo } from "utils/utils"
import React, { useCallback, useRef } from "react"
import useZoomEvents from "hooks/useZoomEvents"
import useCamera from "hooks/useCamera"
import Page from "./page"
import Brush from "./brush"
import state from "state"
export default function Canvas() {
const rCanvas = useRef<SVGSVGElement>(null)
@ -11,10 +14,31 @@ export default function Canvas() {
useCamera(rGroup)
const handlePointerDown = useCallback((e: React.PointerEvent) => {
rCanvas.current.setPointerCapture(e.pointerId)
state.send("POINTED_CANVAS", getPointerEventInfo(e))
}, [])
const handlePointerMove = useCallback((e: React.PointerEvent) => {
state.send("MOVED_POINTER", getPointerEventInfo(e))
}, [])
const handlePointerUp = useCallback((e: React.PointerEvent) => {
rCanvas.current.releasePointerCapture(e.pointerId)
state.send("STOPPED_POINTING", getPointerEventInfo(e))
}, [])
return (
<MainSVG ref={rCanvas} {...events}>
<MainSVG
ref={rCanvas}
{...events}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
<MainGroup ref={rGroup}>
<Page />
<Brush />
</MainGroup>
</MainSVG>
)

94
state/commands/command.ts Normal file
View file

@ -0,0 +1,94 @@
import { Data } from "types"
/* ------------------ Command Class ----------------- */
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()
private undoFn: CommandFn<T>
private doFn: CommandFn<T>
protected restoreBeforeSelectionState: (data: T) => void
protected restoreAfterSelectionState: (data: T) => void
protected saveSelectionState: (data: T) => (data: T) => void
protected manualSelection: boolean
constructor(options: {
type: CommandType
do: CommandFn<T>
undo: CommandFn<T>
manualSelection?: boolean
}) {
this.doFn = options.do
this.undoFn = options.undo
this.manualSelection = options.manualSelection || false
this.restoreBeforeSelectionState = () => () => {
null
}
this.restoreAfterSelectionState = () => () => {
null
}
}
undo = (data: T) => {
if (this.manualSelection) {
this.undoFn(data)
return
}
// We need to set the selection state to what it was before we after we did the command
this.restoreAfterSelectionState(data)
this.undoFn(data)
this.restoreBeforeSelectionState(data)
}
redo = (data: T, initial = false) => {
if (initial) {
this.restoreBeforeSelectionState = this.saveSelectionState(data)
} else {
this.restoreBeforeSelectionState(data)
}
// We need to set the selection state to what it was before we did the command
this.doFn(data, initial)
if (initial) {
this.restoreAfterSelectionState = this.saveSelectionState(data)
}
}
}
/* ---------------- Project Specific ---------------- */
/**
* A subclass of BaseCommand that sends events to our state. In our case, we want our actions
* to mutate the state's data. Actions do not effect the "active states" in
* the app.
*/
export class Command extends BaseCommand<Data> {
saveSelectionState = (data: Data) => {
return (data: Data) => {}
}
}

63
state/commands/history.ts Normal file
View file

@ -0,0 +1,63 @@
import { Data } from "types"
import { BaseCommand } from "./command"
class BaseHistory<T> {
private stack: BaseCommand<T>[] = []
private pointer = -1
private maxLength = 100
private _enabled = true
execute = (data: T, command: BaseCommand<T>) => {
if (this.disabled) return
this.stack = this.stack.slice(0, this.pointer + 1)
this.stack.push(command)
command.redo(data, true)
this.pointer++
if (this.stack.length > this.maxLength) {
this.stack = this.stack.slice(this.stack.length - this.maxLength)
this.pointer = this.maxLength - 1
}
this.save(data)
}
undo = (data: T) => {
if (this.disabled) return
if (this.pointer === -1) return
const command = this.stack[this.pointer]
command.undo(data)
this.pointer--
this.save(data)
}
redo = (data: T) => {
if (this.disabled) return
if (this.pointer === this.stack.length - 1) return
const command = this.stack[this.pointer + 1]
command.redo(data, false)
this.pointer++
this.save(data)
}
save = (data: T) => {
if (typeof window === "undefined") return
if (typeof localStorage === "undefined") return
localStorage.setItem("glob_aldata_v6", JSON.stringify(data))
}
disable = () => {
this._enabled = false
}
enable = () => {
this._enabled = true
}
get disabled() {
return !this._enabled
}
}
export default new BaseHistory<Data>()

44
state/data.ts Normal file
View file

@ -0,0 +1,44 @@
import { Data, ShapeType } from "types"
export const defaultDocument: Data["document"] = {
pages: {
page0: {
id: "page0",
type: "page",
name: "Page 0",
childIndex: 0,
shapes: {
shape0: {
id: "shape0",
type: ShapeType.Circle,
name: "Shape 0",
parentId: "page0",
childIndex: 1,
point: [100, 100],
radius: 50,
rotation: 0,
},
shape1: {
id: "shape1",
type: ShapeType.Rectangle,
name: "Shape 1",
parentId: "page0",
childIndex: 1,
point: [300, 300],
size: [200, 200],
rotation: 0,
},
shape2: {
id: "shape2",
type: ShapeType.Circle,
name: "Shape 2",
parentId: "page0",
childIndex: 2,
point: [200, 800],
radius: 25,
rotation: 0,
},
},
},
},
}

View file

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

View file

@ -0,0 +1,64 @@
import { current } from "immer"
import { Bounds, Data, Shape } from "types"
import BaseSession from "./base-session"
import { screenToWorld, getBoundsFromPoints } from "utils/utils"
import * as vec from "utils/vec"
interface BrushSnapshot {
selectedIds: string[]
shapes: Shape[]
}
export default class BrushSession extends BaseSession {
origin: number[]
snapshot: BrushSnapshot
constructor(data: Data, point: number[]) {
super(data)
this.origin = vec.round(point)
this.snapshot = BrushSession.getSnapshot(data)
}
update = (data: Data, point: number[]) => {
const { origin, snapshot } = this
const bounds = getBoundsFromPoints(origin, point)
data.brush = bounds
const { minX: x, minY: y, width: w, height: h } = bounds
data.selectedIds = [
...snapshot.selectedIds,
...snapshot.shapes.map((shape) => {
return shape.id
}),
]
// Narrow the the items on the screen
data.brush = bounds
}
cancel = (data: Data) => {
data.brush = undefined
data.selectedIds = this.snapshot.selectedIds
}
complete = (data: Data) => {
data.brush = undefined
}
static getSnapshot(data: Data) {
const {
document: { pages },
currentPageId,
} = current(data)
return {
selectedIds: [...data.selectedIds],
shapes: Object.values(pages[currentPageId].shapes),
}
}
}

4
state/sessions/index.ts Normal file
View file

@ -0,0 +1,4 @@
import BaseSession from "./brush-session"
import BrushSession from "./brush-session"
export { BrushSession, BaseSession }

View file

@ -1,56 +1,20 @@
import { createSelectorHook, createState } from "@state-designer/react"
import { clamp, screenToWorld } from "utils/utils"
import * as vec from "utils/vec"
import { Data, ShapeType } from "types"
import { Data } from "types"
import { defaultDocument } from "./data"
import * as Sessions from "./sessions"
const initialData: Data = {
camera: {
point: [0, 0],
zoom: 1,
},
brush: undefined,
pointedId: null,
selectedIds: [],
currentPageId: "page0",
document: {
pages: {
page0: {
id: "page0",
type: "page",
name: "Page 0",
childIndex: 0,
shapes: {
shape0: {
id: "shape0",
type: ShapeType.Circle,
name: "Shape 0",
parentId: "page0",
childIndex: 1,
point: [100, 100],
radius: 50,
rotation: 0,
},
shape1: {
id: "shape1",
type: ShapeType.Rectangle,
name: "Shape 1",
parentId: "page0",
childIndex: 1,
point: [300, 300],
size: [200, 200],
rotation: 0,
},
shape2: {
id: "shape2",
type: ShapeType.Circle,
name: "Shape 2",
parentId: "page0",
childIndex: 2,
point: [200, 800],
radius: 25,
rotation: 0,
},
},
},
},
},
document: defaultDocument,
}
const state = createState({
@ -63,7 +27,37 @@ const state = createState({
do: "panCamera",
},
},
initial: "selecting",
states: {
selecting: {
on: {
POINTED_CANVAS: { to: "brushSelecting" },
},
},
brushSelecting: {
onEnter: "startBrushSession",
on: {
MOVED_POINTER: "updateBrushSession",
STOPPED_POINTING: { do: "completeSession", to: "selecting" },
CANCELLED: { do: "cancelSession", to: "selecting" },
},
},
},
actions: {
cancelSession(data) {
session.cancel(data)
session = undefined
},
completeSession(data) {
session.complete(data)
session = undefined
},
startBrushSession(data, { point }) {
session = new Sessions.BrushSession(data, point)
},
updateBrushSession(data, { point }) {
session.update(data, point)
},
zoomCamera(data, payload: { delta: number; point: number[] }) {
const { camera } = data
const p0 = screenToWorld(payload.point, data)
@ -85,6 +79,8 @@ const state = createState({
},
})
let session: Sessions.BaseSession
export default state
export const useSelector = createSelectorHook(state)

View file

@ -5,7 +5,10 @@ const { styled, global, css, theme, getCssString } = createCss({
...defaultThemeMap,
},
theme: {
colors: {},
colors: {
brushFill: "rgba(0,0,0,.1)",
brushStroke: "rgba(0,0,0,.5)",
},
space: {},
fontSizes: {
0: "10px",

View file

@ -3,9 +3,10 @@ export interface Data {
point: number[]
zoom: number
}
brush?: Bounds
currentPageId: string
selectedIds: string[]
pointedId: string
pointedId?: string
document: {
pages: Record<string, Page>
}
@ -93,3 +94,12 @@ export type Shape =
| RayShape
| LineSegmentShape
| RectangleShape
export interface Bounds {
minX: number
minY: number
maxX: number
maxY: number
width: number
height: number
}

View file

@ -6,6 +6,22 @@ export function screenToWorld(point: number[], data: Data) {
return vec.add(vec.div(point, data.camera.zoom), data.camera.point)
}
export function getBoundsFromPoints(a: number[], b: number[]) {
const minX = Math.min(a[0], b[0])
const maxX = Math.max(a[0], b[0])
const minY = Math.min(a[1], b[1])
const maxY = Math.max(a[1], b[1])
return {
minX,
maxX,
minY,
maxY,
width: maxX - minX,
height: maxY - minY,
}
}
// A helper for getting tangents.
export function getCircleTangentToPoint(
A: number[],
@ -827,3 +843,8 @@ export async function postJsonToEndpoint(
return await d.json()
}
export function getPointerEventInfo(e: React.PointerEvent) {
const { shiftKey, ctrlKey, metaKey, altKey } = e
return { point: [e.clientX, e.clientY], shiftKey, ctrlKey, metaKey, altKey }
}